diff --git a/.github/workflows/python-release.yml b/.github/workflows/python-release.yml index 57411c197..a672ae10d 100644 --- a/.github/workflows/python-release.yml +++ b/.github/workflows/python-release.yml @@ -220,6 +220,7 @@ jobs: PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-14/bin:$PATH LLVM_SYS_140_PREFIX=$HOME/.pecos/deps/llvm-14 MACOSX_DEPLOYMENT_TARGET=13.2 + SDKROOT=$(xcrun --show-sdk-path) CIBW_BEFORE_ALL_MACOS: | curl -sSf https://sh.rustup.rs | sh -s -- -y source $HOME/.cargo/env @@ -287,6 +288,7 @@ jobs: PATH=$HOME/.cargo/bin:$HOME/.pecos/deps/llvm-14/bin:$PATH LLVM_SYS_140_PREFIX=$HOME/.pecos/deps/llvm-14 MACOSX_DEPLOYMENT_TARGET=13.2 + SDKROOT=$(xcrun --show-sdk-path) CIBW_BEFORE_ALL_MACOS: | source $HOME/.cargo/env 2>/dev/null || { curl -sSf https://sh.rustup.rs | sh -s -- -y && source $HOME/.cargo/env; } if [ ! -d "$HOME/.pecos/deps/llvm-14/bin" ]; then diff --git a/.github/workflows/python-version-consistency.yml b/.github/workflows/python-version-consistency.yml index 65989d385..16bd806e9 100644 --- a/.github/workflows/python-version-consistency.yml +++ b/.github/workflows/python-version-consistency.yml @@ -3,46 +3,28 @@ name: Python Version Consistency Check on: push: paths: + - 'pyproject.toml' - 'python/**/pyproject.toml' + - 'scripts/check_python_workspace.py' + - '.github/workflows/python-version-consistency.yml' pull_request: paths: + - 'pyproject.toml' - 'python/**/pyproject.toml' + - 'scripts/check_python_workspace.py' + - '.github/workflows/python-version-consistency.yml' jobs: - check_versions: + check_python_workspace: runs-on: ubuntu-latest steps: - name: Checkout code uses: actions/checkout@v6 - - name: Check version consistency - run: | - # Find all pyproject.toml files in the python/ directory - PYPROJECT_FILES=$(find python -name pyproject.toml) + - name: Set up Python + uses: actions/setup-python@v6 + with: + python-version: "3.11" - # Initialize variables - FIRST_VERSION="" - INCONSISTENT=false - - # Check each pyproject.toml file - for file in $PYPROJECT_FILES; do - VERSION=$(grep -oP 'version = "\K[^"]+' $file) - - if [ -z "$FIRST_VERSION" ]; then - FIRST_VERSION=$VERSION - echo "Reference version: $FIRST_VERSION (from $file)" - elif [ "$VERSION" != "$FIRST_VERSION" ]; then - echo "Inconsistent version found in $file: $VERSION" - INCONSISTENT=true - else - echo "Consistent version found in $file: $VERSION" - fi - done - - # Exit with error if versions are inconsistent - if [ "$INCONSISTENT" = true ]; then - echo "Error: Inconsistent versions found across pyproject.toml files" - exit 1 - else - echo "Success: All pyproject.toml files have consistent versions" - fi + - name: Check Python workspace metadata + run: python scripts/check_python_workspace.py diff --git a/.typos.toml b/.typos.toml index 05b3f16f9..716e3bb44 100644 --- a/.typos.toml +++ b/.typos.toml @@ -40,3 +40,5 @@ egative = "egative" agger = "agger" # Ruff rule code prefix (flake8-copyright) CPY = "CPY" +# Abbreviation for "undetectable" in fault enumeration debug output +UNDET = "UNDET" diff --git a/Cargo.lock b/Cargo.lock index 3f9b368ea..167eb8a3f 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -85,6 +85,15 @@ version = "0.1.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4b46cbb362ab8752921c97e041f5e366ee6297bd428a31275b9fcf1e380f7299" +[[package]] +name = "ansi_term" +version = "0.12.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d52a9bb7ec0cf484c551830a7ce27bd20d67eac647e1befb56b0be4ee39a55d2" +dependencies = [ + "winapi", +] + [[package]] name = "anstream" version = "1.0.0" @@ -121,7 +130,7 @@ version = "1.1.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "40c48f72fd53cd289104fc64099abca73db4166ad86ea0b4341abe65af83dadc" dependencies = [ - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -132,7 +141,7 @@ checksum = "291e6a250ff86cd4a820112fb8898808a366d8f9f58ce16d1f538353ad55747d" dependencies = [ "anstyle", "once_cell_polyfill", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -254,9 +263,9 @@ dependencies = [ [[package]] name = "assert_cmd" -version = "2.2.1" +version = "2.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "39bae1d3fa576f7c6519514180a72559268dd7d1fe104070956cb687bc6673bd" +checksum = "2aa3a22042e45de04255c7bf3626e239f450200fd0493c1e382263544b20aea6" dependencies = [ "anstyle", "bstr", @@ -284,6 +293,17 @@ version = "1.1.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1505bd5d3d116872e7271a6d4e16d81d0c8570876c8de68093a09ac269d8aac0" +[[package]] +name = "atty" +version = "0.2.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d9b39be18770d11421cdb1b9947a45dd3f37e93092cbf377614828a319d5fee8" +dependencies = [ + "hermit-abi 0.1.19", + "libc", + "winapi", +] + [[package]] name = "autocfg" version = "1.5.0" @@ -355,7 +375,7 @@ dependencies = [ "rand 0.10.1", "rand_xoshiro 0.8.0", "rapidhash", - "wide 1.3.0", + "wide 1.4.0", ] [[package]] @@ -364,7 +384,7 @@ version = "0.72.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "993776b509cfb49c750f11b8f07a46fa23e0a1386ffc01fb1e7d343efc387895" dependencies = [ - "bitflags", + "bitflags 2.11.1", "cexpr", "clang-sys", "itertools 0.13.0", @@ -438,6 +458,12 @@ version = "0.9.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b71798fca2c1fe1086445a7258a4bc81e6e49dcd24c8d0dd9a1e57395b603f51" +[[package]] +name = "bitflags" +version = "1.3.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" + [[package]] name = "bitflags" version = "2.11.1" @@ -503,6 +529,24 @@ version = "0.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "38c99613cb3cd7429889a08dfcf651721ca971c86afa30798461f8eee994de47" +[[package]] +name = "bp" +version = "0.1.0" +source = "git+https://github.com/yuewuo/mwpf?tag=v0.2.12#b8444428f999457208c4b0956f3f1c745a0ec2d5" +dependencies = [ + "float-cmp", + "rand 0.8.6", +] + +[[package]] +name = "bs58" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "bf88ba1141d185c399bee5288d850d63b8369520c1eafc32a0430b5b6c287bf4" +dependencies = [ + "tinyvec", +] + [[package]] name = "bstr" version = "1.12.1" @@ -584,9 +628,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", @@ -614,9 +658,9 @@ checksum = "37b2a672a2cb129a2e41c10b1224bb368f9f37a2b16b612598138befd7b37eb5" [[package]] name = "cc" -version = "1.2.60" +version = "1.2.62" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "43c5703da9466b66a946814e1adf53ea2c90f10063b86290cc9eb67ce3478a20" +checksum = "a1dce859f0832a7d088c4f1119888ab94ef4b5d6795d1ce05afb7fe159d79f98" dependencies = [ "find-msvc-tools", "jobserver", @@ -624,12 +668,6 @@ dependencies = [ "shlex", ] -[[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" @@ -725,6 +763,21 @@ dependencies = [ "libloading 0.8.9", ] +[[package]] +name = "clap" +version = "2.34.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a0610544180c38b88101fecf2dd634b174a62eef6946f84dfc6a7127512b381c" +dependencies = [ + "ansi_term", + "atty", + "bitflags 1.3.2", + "strsim 0.8.0", + "textwrap", + "unicode-width 0.1.14", + "vec_map", +] + [[package]] name = "clap" version = "4.6.1" @@ -744,16 +797,16 @@ dependencies = [ "anstream", "anstyle", "clap_lex", - "strsim", + "strsim 0.11.1", ] [[package]] name = "clap_complete" -version = "4.6.2" +version = "4.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3ff7a1dccbdd8b078c2bdebff47e404615151534d5043da397ec50286816f9cb" +checksum = "e0a7a9bfdb35811f9e59832f0f05975114d2251b415fb534108e6f34060fd772" dependencies = [ - "clap", + "clap 4.6.1", ] [[package]] @@ -800,7 +853,7 @@ checksum = "af491d569909a7e4dee0ad7db7f5341fef5c614d5b8ec8cf765732aba3cff681" dependencies = [ "serde", "termcolor", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -827,7 +880,7 @@ checksum = "d64e8af5551369d19cf50138de61f1c42074ab970f74e99be916646777f8fc87" dependencies = [ "encode_unicode", "libc", - "unicode-width", + "unicode-width 0.2.2", "windows-sys 0.61.2", ] @@ -879,6 +932,15 @@ dependencies = [ "winapi", ] +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -899,27 +961,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "adc822414b18d1f5b1b33ce1441534e311e62fef86ebb5b9d382af857d0272c9" +checksum = "f8628cc4ba7f88a9205a7ee42327697abc61195a1e3d92cfae172d6a946e722e" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8c646808b06f4532478d8d6057d74f15c3322f10d995d9486e7dcea405bf521a" +checksum = "d582754487e6c9a065a91c42ccf1bdd8d5977af33468dac5ae9bec0ce88acb3e" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7b5996f01a686b2349cdb379083ec5ad3e8cb8767fb2d495d3a4f2ee4163a18d" +checksum = "fb59c81ace12ee7c33074db7903d4d75d1f40b28cd3e8e6f491de57b29129eb9" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -927,9 +989,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "523fea83273f6a985520f57788809a4de2165794d9ab00fb1254fceb4f5aa00c" +checksum = "f25c06993a681be9cf3140798a3d4ac5bec955e7444416a2fdc87fda8567285d" dependencies = [ "serde", "serde_derive", @@ -938,9 +1000,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d73d1e372730b5f64ed1a2bd9f01fe4686c8ec14a28034e3084e530c8d951878" +checksum = "27b61f95c5a211918f5d336254a61a488b36a5818de47a868e8c4658dce9cccc" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -966,9 +1028,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b0319c18165e93dc1ebf78946a8da0b1c341c95b4a39729a69574671639bdb5f" +checksum = "0b85aa822fce72080d041d7c2cf7c3f5c6ecdea7afae68379ba4ef85269c4fa5" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -979,24 +1041,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9195cd8aeecb55e401aa96b2eaa55921636e8246c127ed7908f7ef7e0d40f270" +checksum = "833eb9fc89326cd072cc19e96892f09b5692c0dfe17cd4da2858ba30c2cd85c0" [[package]] name = "cranelift-control" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8976c2154b74136322befc74222ab5c7249edd7e2604f8cbef2b94975541ffb9" +checksum = "9d005320f487e6e8a3edcc7f2fd4f43fcc9946d1013bf206ea649789ac1617fc" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6038b3147c7982f4951150d5f96c7c06c1e7214b99d4b4a98607aadf8ded89d1" +checksum = "5e62ef34c6e720f347a79ece043e8584e242d168911da640bac654a33a6aaaf5" dependencies = [ "cranelift-bitset", "serde", @@ -1006,9 +1068,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4cbd294abe236e23cc3d907b0936226b6a8342db7636daa9c7c72be1e323420e" +checksum = "dfa2ad00399dd47e7e7e33cb1dc23b0e39ed9dcd01e8f026fc37af91655031b8" dependencies = [ "cranelift-codegen", "log", @@ -1018,15 +1080,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b5a90b6ed3aba84189352a87badeb93b2126d3724225a42dc67fdce53d1b139c" +checksum = "02c51975ed217b4e8e5a7fd11e9ec83a96104bdff311dddcb505d1d8a9fd7fc6" [[package]] name = "cranelift-native" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c3ec0cc1a54e22925eacf4fc3dc815f907734d3b377899d19d52bec04863e853" +checksum = "f9b1889e00da9729d8f8525f3c12998ded86ea709058ff844ebe00b97548de0e" dependencies = [ "cranelift-codegen", "libc", @@ -1035,9 +1097,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.130.2" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "948865622f87f30907bb46fbb081b235ae63c1896a99a83c26a003305c1fa82d" +checksum = "d5a8f82fd5124f009f72167e60139245cd3b56cfd4b53050f22110c48c5f4da1" [[package]] name = "crc" @@ -1050,9 +1112,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" @@ -1073,7 +1135,7 @@ dependencies = [ "anes", "cast", "ciborium", - "clap", + "clap 4.6.1", "criterion-plot", "itertools 0.13.0", "num-traits", @@ -1178,6 +1240,12 @@ dependencies = [ "memchr", ] +[[package]] +name = "cute" +version = "0.3.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "45e700c2d1c3feea9b695e79b2dfeeb93040556a58c556fae23f71b1e6b449fd" + [[package]] name = "cxx" version = "1.0.194" @@ -1214,7 +1282,7 @@ version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328" dependencies = [ - "clap", + "clap 4.6.1", "codespan-reporting", "indexmap 2.14.0", "proc-macro2", @@ -1259,7 +1327,7 @@ dependencies = [ "ident_case", "proc-macro2", "quote", - "strsim", + "strsim 0.11.1", "syn 2.0.117", ] @@ -1434,9 +1502,9 @@ 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", @@ -1471,7 +1539,7 @@ dependencies = [ "libc", "option-ext", "redox_users 0.5.2", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -1491,7 +1559,7 @@ version = "0.3.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1e0e367e4e7da84520dedcac1901e4da967309406d1e51017ae1abfb97adbd38" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2", ] @@ -1646,7 +1714,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -1657,13 +1725,12 @@ checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" [[package]] name = "filetime" -version = "0.2.27" +version = "0.2.28" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f98844151eee8917efc50bd9e8318cb963ae8b297431495d3f758616ea5c57db" +checksum = "2d5b2eef6fafbf69f877e55509ce5b11a760690ac9700a2921be067aa6afaef6" dependencies = [ "cfg-if", "libc", - "libredox", ] [[package]] @@ -1718,6 +1785,15 @@ dependencies = [ "miniz_oxide", ] +[[package]] +name = "float-cmp" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b09cf3155332e944990140d967ff5eceb70df778b34f77d8075db46e4704e6d8" +dependencies = [ + "num-traits", +] + [[package]] name = "fnv" version = "1.0.7" @@ -1766,7 +1842,7 @@ dependencies = [ "cc", "cfg-if", "chrono", - "clap", + "clap 4.6.1", "core_affinity", "derivative", "lazy_static", @@ -2117,7 +2193,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b89c83349105e3732062a895becfc71a8f921bb71ecbbdd8ff99263e3b53a0ca" dependencies = [ - "bitflags", + "bitflags 2.11.1", "gpu-descriptor-types", "hashbrown 0.15.5", ] @@ -2128,7 +2204,7 @@ version = "0.2.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdf242682df893b86f33a73828fb09ca4b2d3bb6cc95249707fc684d27484b91" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -2166,7 +2242,10 @@ version = "0.15.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9229cfe53dfd69f0609a49f65461bd93001ea1ef889cd5529dd176593f5338a1" dependencies = [ + "allocator-api2", + "equivalent", "foldhash 0.1.5", + "rayon", ] [[package]] @@ -2184,9 +2263,26 @@ 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" +dependencies = [ + "foldhash 0.2.0", +] + +[[package]] +name = "heapz" +version = "1.1.4" +source = "git+https://github.com/yuewuo/mwpf?tag=v0.2.12#b8444428f999457208c4b0956f3f1c745a0ec2d5" + +[[package]] +name = "heck" +version = "0.3.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6d621efb26863f0e9924c6ac577e8275e5e6b77455db64ffa6c65c904e9e132c" +dependencies = [ + "unicode-segmentation", +] [[package]] name = "heck" @@ -2200,6 +2296,15 @@ version = "0.5.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2304e00983f87ffb38b55b444b5e3b60a884b5d30c0fca7d82fe33449bbe55ea" +[[package]] +name = "hermit-abi" +version = "0.1.19" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "62b467343b94ba476dcb2500d242dadbb39557df889310ac77c5d99100aaac33" +dependencies = [ + "libc", +] + [[package]] name = "hermit-abi" version = "0.5.2" @@ -2218,6 +2323,25 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "dfa686283ad6dd069f105e5ab091b04c62850d3e4cf5d67debad1933f55023df" +[[package]] +name = "highs" +version = "1.6.1" +source = "git+https://github.com/yuewuo/mwpf?tag=v0.2.12#b8444428f999457208c4b0956f3f1c745a0ec2d5" +dependencies = [ + "highs-sys", + "log", +] + +[[package]] +name = "highs-sys" +version = "1.14.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "11a9fa9dea5b5f277075460b695db30ad7e5ce28554f2dd27c312c640acd101f" +dependencies = [ + "bindgen", + "cmake", +] + [[package]] name = "html-escape" version = "0.2.13" @@ -2379,9 +2503,9 @@ dependencies = [ [[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", ] @@ -2575,9 +2699,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", @@ -2591,7 +2715,6 @@ checksum = "bd070e393353796e801d209ad339e89596eb4c8d430d18ede6a1cced8fafbd99" dependencies = [ "autocfg", "hashbrown 0.12.3", - "rayon", "serde", ] @@ -2602,7 +2725,8 @@ 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", ] @@ -2616,7 +2740,7 @@ dependencies = [ "console", "portable-atomic", "rayon", - "unicode-width", + "unicode-width 0.2.2", "unit-prefix", "web-time", ] @@ -2681,25 +2805,15 @@ 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" version = "0.4.17" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3640c1c38b8e4e43584d8df18be5fc6b0aa314ce6ebf51b53313d4306cca8e46" dependencies = [ - "hermit-abi", + "hermit-abi 0.5.2", "libc", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -2743,9 +2857,9 @@ checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" [[package]] name = "jiff" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1a3546dc96b6d42c5f24902af9e2538e82e39ad350b0c766eb3fbf2d8f3d8359" +checksum = "f00b5dbd620d61dfdcb6007c9c1f6054ebd75319f163d886a9055cec1155073d" dependencies = [ "jiff-static", "log", @@ -2756,9 +2870,9 @@ dependencies = [ [[package]] name = "jiff-static" -version = "0.2.23" +version = "0.2.24" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2a8c8b344124222efd714b73bb41f8b5120b27a7cc1c75593a6ff768d9d05aa4" +checksum = "e000de030ff8022ea1da3f466fbb0f3a809f5e51ed31f6dd931c35181ad8e6d7" dependencies = [ "proc-macro2", "quote", @@ -2767,18 +2881,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 2.0.18", "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 2.0.117", ] [[package]] @@ -2821,9 +2949,9 @@ dependencies = [ [[package]] name = "js-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "2964e92d1d9dc3364cae4d718d93f227e3abb088e747d92e0395bfdedf1c12ca" +checksum = "67df7112613f8bfd9150013a0314e196f4800d3201ae742489d999db2f979f08" dependencies = [ "cfg-if", "futures-util", @@ -2911,9 +3039,9 @@ checksum = "b3a6a8c165077efc8f3a971534c50ea6a1a18b329ef4a66e897a7e3a1494565f" [[package]] name = "libc" -version = "0.2.185" +version = "0.2.186" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "52ff2c0fe9bc6cb6b14a0592c2ff4fa9ceb83eea9db979b0487cd054946a2b8f" +checksum = "68ab91017fe16c622486840e4c83c9a37afeff978bd239b5293d61ece587de66" [[package]] name = "libloading" @@ -2947,10 +3075,7 @@ version = "0.1.16" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e02f3bb43d335493c96bf3fd3a321600bf6bd07ed34bc64118e9293bdffea46c" dependencies = [ - "bitflags", "libc", - "plain", - "redox_syscall 0.7.4", ] [[package]] @@ -2993,6 +3118,12 @@ dependencies = [ "semver", ] +[[package]] +name = "lnexp" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f98924c778103b33dcf6beb1af911977e5d65c6cae74b45e133b3c90bd6db948" + [[package]] name = "lock_api" version = "0.4.14" @@ -3066,6 +3197,12 @@ dependencies = [ "libc", ] +[[package]] +name = "maplit" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3e2e65a1a2e43cfcb47a895c4c8b10d1f4a61097f9f254f183aee60cad9c651d" + [[package]] name = "matrixmultiply" version = "0.3.10" @@ -3091,6 +3228,12 @@ dependencies = [ "rustix", ] +[[package]] +name = "min_max_macros" +version = "0.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "131a74fe105e2662ce9646e3a3af4c0eeb19bc0e5936852bf81341bfc415b9f2" + [[package]] name = "minimal-lexical" version = "0.2.1" @@ -3118,15 +3261,63 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "more-asserts" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1fafa6961cabd9c63bcd77a45d7e3b7f3b552b70417831fb0f56db717e72407e" + +[[package]] +name = "mwpf" +version = "0.2.12" +source = "git+https://github.com/yuewuo/mwpf?tag=v0.2.12#b8444428f999457208c4b0956f3f1c745a0ec2d5" +dependencies = [ + "base64", + "bp", + "cfg-if", + "chrono", + "ciborium", + "clap 4.6.1", + "derivative", + "flate2", + "getrandom 0.2.17", + "hashbrown 0.15.5", + "heapz", + "highs", + "itertools 0.14.0", + "lazy_static", + "lnexp", + "maplit", + "more-asserts", + "num-bigint", + "num-rational", + "num-traits", + "parking_lot", + "pheap", + "prettytable-rs", + "priority-queue 2.7.0", + "rand 0.8.6", + "rand_xoshiro 0.6.0", + "serde", + "serde-wasm-bindgen", + "serde_json", + "serde_variant", + "slp", + "sugar", + "tempfile", + "thread-priority", + "urlencoding", +] + [[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 0.7.6", "bit-set 0.9.1", - "bitflags", + "bitflags 2.11.1", "cfg-if", "cfg_aliases", "codespan-reporting", @@ -3361,6 +3552,7 @@ dependencies = [ "num-bigint", "num-integer", "num-traits", + "serde", ] [[package]] @@ -3379,7 +3571,7 @@ version = "1.17.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "91df4bbde75afed763b708b7eee1e8e7651e02d97f6d5dd763e89367e957b23b" dependencies = [ - "hermit-abi", + "hermit-abi 0.5.2", "libc", ] @@ -3398,7 +3590,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "2a180dd8642fa45cdb7dd721cd4c11b1cadd4929ce112ebd8b9f5803cc79d536" dependencies = [ - "bitflags", + "bitflags 2.11.1", "dispatch2", "objc2", ] @@ -3415,7 +3607,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3e0adef53c21f888deb4fa59fc59f7eb17404926ee8a6f59f5df0fd7f9f3272" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", ] @@ -3426,7 +3618,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a0125f776a10d00af4152d74616409f0d4a2053a6f57fa5b7d6aa2854ac04794" dependencies = [ - "bitflags", + "bitflags 2.11.1", "block2", "objc2", "objc2-foundation", @@ -3438,7 +3630,7 @@ version = "0.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "96c1358452b371bf9f104e21ec536d37a650eb10f7ee379fff67d2e08d537f1f" dependencies = [ - "bitflags", + "bitflags 2.11.1", "objc2", "objc2-core-foundation", "objc2-foundation", @@ -3447,12 +3639,12 @@ dependencies = [ [[package]] name = "object" -version = "0.38.1" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" dependencies = [ "crc32fast", - "hashbrown 0.16.1", + "hashbrown 0.17.1", "indexmap 2.14.0", "memchr", ] @@ -3565,7 +3757,7 @@ checksum = "2621685985a2ebf1c516881c026032ac7deafcda1a2c9b7850dc81e3dfcb64c1" dependencies = [ "cfg-if", "libc", - "redox_syscall 0.5.18", + "redox_syscall", "smallvec", "windows-link", ] @@ -3578,9 +3770,9 @@ checksum = "57c0d7b74b563b49d38dae00a0c37d4d6de9b432382b2892f0574ddcae73fd0a" [[package]] name = "pastey" -version = "0.2.1" +version = "0.2.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b867cad97c0791bbd3aaa6472142568c6c9e8f71937e98379f584cfb0cf35bec" +checksum = "c5a797f0e07bdf071d15742978fc3128ec6c22891c31a3a931513263904c982a" [[package]] name = "pbr" @@ -3653,6 +3845,7 @@ dependencies = [ "ndarray 0.17.2", "pecos-build", "pecos-decoder-core", + "pecos-pymatching", "thiserror 2.0.18", ] @@ -3661,7 +3854,7 @@ name = "pecos-cli" version = "0.2.0-dev.0" dependencies = [ "assert_cmd", - "clap", + "clap 4.6.1", "clap_complete", "env_logger", "log", @@ -3735,6 +3928,8 @@ version = "0.2.0-dev.0" dependencies = [ "anyhow", "ndarray 0.17.2", + "pecos-random", + "rayon", "thiserror 2.0.18", ] @@ -3746,16 +3941,33 @@ dependencies = [ "pecos-decoder-core", "pecos-fusion-blossom", "pecos-ldpc-decoders", + "pecos-mwpf", "pecos-pymatching", "pecos-relay-bp", "pecos-tesseract", + "pecos-uf-decoder", +] + +[[package]] +name = "pecos-eeg" +version = "0.2.0-dev.0" +dependencies = [ + "pecos-core", + "pecos-qec", + "pecos-quantum", + "pecos-random", + "pecos-simulators", + "rayon", + "serde_json", + "smallvec", + "thiserror 2.0.18", ] [[package]] name = "pecos-engines" version = "0.2.0-dev.0" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bitvec", "bytemuck", "dyn-clone", @@ -3779,7 +3991,7 @@ dependencies = [ "pecos-random", "pecos-simulators", "rand 0.10.1", - "wide 1.3.0", + "wide 1.4.0", ] [[package]] @@ -3812,6 +4024,7 @@ dependencies = [ "fusion-blossom", "ndarray 0.17.2", "pecos-decoder-core", + "serde_json", "thiserror 2.0.18", ] @@ -3898,6 +4111,21 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "pecos-lindblad" +version = "0.2.0-dev.0" +dependencies = [ + "approx 0.5.1", + "num-complex 0.4.6", + "pecos-qec", + "pecos-quantum", + "proptest", + "rand 0.10.1", + "serde", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pecos-llvm" version = "0.2.0-dev.0" @@ -3909,11 +4137,23 @@ dependencies = [ "pecos-core", ] +[[package]] +name = "pecos-mwpf" +version = "0.2.0-dev.0" +dependencies = [ + "mwpf", + "ndarray 0.17.2", + "pecos-decoder-core", + "serde_json", + "thiserror 2.0.18", +] + [[package]] name = "pecos-neo" version = "0.2.0-dev.0" dependencies = [ "criterion", + "num-complex 0.4.6", "num_cpus", "pecos-core", "pecos-engines", @@ -4035,15 +4275,17 @@ dependencies = [ "ndarray 0.17.2", "pecos-core", "pecos-decoder-core", + "pecos-num", "pecos-quantum", "pecos-random", "pecos-simulators", "rand 0.10.1", "rand_core 0.10.1", "rayon", + "serde_json", "smallvec", "thiserror 2.0.18", - "wide 1.3.0", + "wide 1.4.0", ] [[package]] @@ -4099,7 +4341,9 @@ dependencies = [ "num-complex 0.4.6", "pecos-core", "pecos-num", + "pecos-random", "pecos-simulators", + "serde_json", "smallvec", "tket", ] @@ -4113,7 +4357,7 @@ dependencies = [ "rand_xoshiro 0.8.0", "random_tester", "rapidhash", - "wide 1.3.0", + "wide 1.4.0", ] [[package]] @@ -4136,6 +4380,7 @@ dependencies = [ "dirs", "libc", "log", + "nalgebra", "ndarray 0.17.2", "num-complex 0.4.6", "parking_lot", @@ -4161,6 +4406,7 @@ dependencies = [ "pecos-wasm", "pyo3", "rand 0.10.1", + "rayon", "serde_json", "tempfile", ] @@ -4184,9 +4430,18 @@ version = "0.2.0-dev.0" dependencies = [ "num-complex 0.4.6", "pecos-core", + "pecos-eeg", + "pecos-neo", + "pecos-qec", + "pecos-quantum", + "pecos-random", "pecos-simulators", "pecos-stab-tn", "pyo3", + "rayon", + "serde", + "serde_json", + "smallvec", ] [[package]] @@ -4206,7 +4461,7 @@ name = "pecos-selene-core" version = "0.2.0-dev.0" dependencies = [ "anyhow", - "clap", + "clap 4.6.1", "pecos-core", "pecos-simulators", "selene-core 0.2.1", @@ -4249,7 +4504,7 @@ name = "pecos-selene-stabilizer" version = "0.2.0-dev.0" dependencies = [ "anyhow", - "clap", + "clap 4.6.1", "pecos-core", "pecos-simulators", "selene-core 0.2.1", @@ -4269,6 +4524,7 @@ dependencies = [ name = "pecos-simulators" version = "0.2.0-dev.0" dependencies = [ + "nalgebra", "num-complex 0.4.6", "paste", "pecos-core", @@ -4277,7 +4533,7 @@ dependencies = [ "rand 0.10.1", "rayon", "smallvec", - "wide 1.3.0", + "wide 1.4.0", ] [[package]] @@ -4311,6 +4567,16 @@ dependencies = [ "thiserror 2.0.18", ] +[[package]] +name = "pecos-uf-decoder" +version = "0.2.0-dev.0" +dependencies = [ + "fastrand", + "pecos-decoder-core", + "pecos-random", + "rayon", +] + [[package]] name = "pecos-wasm" version = "0.2.0-dev.0" @@ -4405,6 +4671,14 @@ dependencies = [ "serde", ] +[[package]] +name = "pheap" +version = "0.3.0" +source = "git+https://github.com/yuewuo/mwpf?tag=v0.2.12#b8444428f999457208c4b0956f3f1c745a0ec2d5" +dependencies = [ + "num-traits", +] + [[package]] name = "phf_shared" version = "0.11.3" @@ -4426,12 +4700,6 @@ version = "0.3.33" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "19f132c84eca552bf34cab8ec81f1c1dcc229b811638f9d283dceabe58c5569e" -[[package]] -name = "plain" -version = "0.2.3" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4596b6d070b27117e987119b4dac604f3c58cfb0b191112e24771b2faeac1a6" - [[package]] name = "plotters" version = "0.3.7" @@ -4580,7 +4848,7 @@ checksum = "0d22152487193190344590e4f30e219cf3fe140d9e7a3fdb683d82aa2c5f4156" dependencies = [ "arrayvec 0.5.2", "typed-arena", - "unicode-width", + "unicode-width 0.2.2", ] [[package]] @@ -4593,6 +4861,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "prettytable-rs" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "eea25e07510aa6ab6547308ebe3c036016d162b8da920dbb079e3ba8acf3d95a" +dependencies = [ + "csv", + "encode_unicode", + "is-terminal", + "lazy_static", + "term", + "unicode-width 0.1.14", +] + [[package]] name = "priority-queue" version = "1.4.0" @@ -4623,6 +4905,30 @@ dependencies = [ "toml_edit", ] +[[package]] +name = "proc-macro-error" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "da25490ff9892aab3fcf7c36f08cfb902dd3e71ca0f9f9517bea02a73a5ce38c" +dependencies = [ + "proc-macro-error-attr", + "proc-macro2", + "quote", + "syn 1.0.109", + "version_check", +] + +[[package]] +name = "proc-macro-error-attr" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a1be40180e52ecc98ad80b184934baf3d0d29f979574e439af5a55274b35f869" +dependencies = [ + "proc-macro2", + "quote", + "version_check", +] + [[package]] name = "proc-macro2" version = "1.0.106" @@ -4647,9 +4953,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 = "proptest" @@ -4659,7 +4965,7 @@ checksum = "4b45fcc2344c680f5025fe57779faef368840d0bd1f42f216291f0dc4ace4744" dependencies = [ "bit-set 0.8.0", "bit-vec 0.8.0", - "bitflags", + "bitflags 2.11.1", "num-traits", "rand 0.9.4", "rand_chacha 0.9.0", @@ -4672,9 +4978,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "43.0.2" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7ec12fe19a9588315a49fe5704502a9c02d6a198303314b0c7c86123b06d29e5" +checksum = "b9326e3a0093d170582cf64ed9e4cf253b8aac155ec4a294ff62330450bbf094" dependencies = [ "cranelift-bitset", "log", @@ -4684,9 +4990,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "43.0.2" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "36f7d5ef31ebf1b46cd7e722ffef934e670d7e462f49aa01cde07b9b76dca580" +checksum = "00c6433917e3789605b1f4cd2a589f637ff17212344e7fa5ba99544625ba52c7" dependencies = [ "proc-macro2", "quote", @@ -4844,7 +5150,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d7aeb420f81a3c9e632e70cf19a36bfeec45e25a749230c1e99c95486946f9f" dependencies = [ "approx 0.5.1", - "clap", + "clap 4.6.1", "derive_more 1.0.0", "env_logger", "itertools 0.13.0", @@ -5099,16 +5405,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags", -] - -[[package]] -name = "redox_syscall" -version = "0.7.4" -source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f450ad9c3b1da563fb6948a8e0fb0fb9269711c9c73d9ea1de5058c79c8d643a" -dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -5161,7 +5458,7 @@ checksum = "de2c52737737f8609e94f975dee22854a2d5c125772d4b1cf292120f4d45c186" dependencies = [ "allocator-api2", "bumpalo", - "hashbrown 0.17.0", + "hashbrown 0.17.1", "log", "rustc-hash 2.1.2", "smallvec", @@ -5253,9 +5550,9 @@ checksum = "19b30a45b0cd0bcca8037f3d0dc3421eaf95327a17cad11964fb8179b4fc4832" [[package]] name = "reqwest" -version = "0.13.2" +version = "0.13.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ab3f43e3283ab1488b624b44b0e988d0acea0b3214e694730a055cb6b2efa801" +checksum = "62e0021ea2c22aed41653bc7e1419abb2c97e038ff2c33d0e1309e49a97deec0" dependencies = [ "base64", "bytes", @@ -5308,7 +5605,7 @@ version = "0.12.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4147b952f3f819eca0e99527022f7d6a8d05f111aeb0a62960c74eb283bec8fc" dependencies = [ - "bitflags", + "bitflags 2.11.1", "once_cell", "serde", "serde_derive", @@ -5381,6 +5678,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "1.1.0" @@ -5408,18 +5711,18 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags", + "bitflags 2.11.1", "errno", "libc", "linux-raw-sys", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] name = "rustls" -version = "0.23.38" +version = "0.23.40" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "69f9466fb2c14ea04357e91413efb882e2a6d4a406e625449bc0a5d360d53a21" +checksum = "ef86cd5876211988985292b91c96a8f2d298df24e75989a43a3c73f2d4d8168b" dependencies = [ "aws-lc-rs", "once_cell", @@ -5443,9 +5746,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", @@ -5453,9 +5756,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", @@ -5469,7 +5772,7 @@ dependencies = [ "security-framework", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -5504,8 +5807,8 @@ checksum = "aaeee6f84153fd6f62507fc22bfe9499c8485075b44186dcbb918166ef75116f" dependencies = [ "fixedbitset 0.5.7", "foldhash 0.1.5", - "hashbrown 0.14.5", - "indexmap 1.9.3", + "hashbrown 0.15.5", + "indexmap 2.14.0", "ndarray 0.16.1", "num-traits", "petgraph 0.8.3", @@ -5626,7 +5929,7 @@ version = "3.7.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b7f4bc775c73d9a02cde8bf7b2ec4c9d12743edf609006c7facc23998404cd1d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "core-foundation", "core-foundation-sys", "libc", @@ -5707,6 +6010,17 @@ dependencies = [ "serde_derive", ] +[[package]] +name = "serde-wasm-bindgen" +version = "0.6.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8302e169f0eddcc139c70f139d19d6467353af16f9fce27e8c30158036a1e16b" +dependencies = [ + "js-sys", + "serde", + "wasm-bindgen", +] + [[package]] name = "serde_core" version = "1.0.228" @@ -5760,13 +6074,23 @@ dependencies = [ "serde_core", ] +[[package]] +name = "serde_variant" +version = "0.1.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0a0068df419f9d9b6488fdded3f1c818522cdea328e02ce9d9f147380265a432" +dependencies = [ + "serde", +] + [[package]] name = "serde_with" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "dd5414fad8e6907dbdd5bc441a50ae8d6e26151a03b1de04d89a5576de61d01f" +checksum = "e72c1c2cb7b223fafb600a619537a871c2818583d619401b785e7c0b746ccde2" dependencies = [ "base64", + "bs58", "chrono", "hex", "indexmap 1.9.3", @@ -5781,9 +6105,9 @@ dependencies = [ [[package]] name = "serde_with_macros" -version = "3.18.0" +version = "3.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d3db8978e608f1fe7357e211969fd9abdcae80bac1ba7a3369bb7eb6b404eb65" +checksum = "b90c488738ecb4fb0262f41f43bc40efc5868d9fb744319ddf5f5317f417bfac" dependencies = [ "darling", "proc-macro2", @@ -5827,7 +6151,7 @@ checksum = "446ba717509524cb3f22f17ecc096f10f4822d76ab5c0b9822c5f9c284e825f4" dependencies = [ "cfg-if", "cpufeatures 0.3.0", - "digest 0.11.2", + "digest 0.11.3", ] [[package]] @@ -5855,6 +6179,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 = "similar" version = "2.7.0" @@ -5863,9 +6203,9 @@ checksum = "bbbb5d9659141646ae647b42fe094daf6c6192d1620870b449d9557f748b2daa" [[package]] name = "siphasher" -version = "1.0.2" +version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b2aa850e253778c88a04c3d7323b043aeda9d3e30d5971937c1855769763678e" +checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" [[package]] name = "slab" @@ -5892,6 +6232,20 @@ dependencies = [ "version_check", ] +[[package]] +name = "slp" +version = "0.2.0" +source = "git+https://github.com/yuewuo/mwpf?tag=v0.2.12#b8444428f999457208c4b0956f3f1c745a0ec2d5" +dependencies = [ + "num-bigint", + "num-rational", + "num-traits", + "pest", + "pest_derive", + "rayon", + "structopt", +] + [[package]] name = "smallvec" version = "1.15.1" @@ -5918,7 +6272,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3a766e1110788c36f4fa1c2b71b387a7815aa65f88ce0229841826633d93723e" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5927,7 +6281,7 @@ version = "0.4.0+sdk-1.4.341.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d9571ea910ebd84c86af4b3ed27f9dbdc6ad06f17c5f96146b2b671e2976744f" dependencies = [ - "bitflags", + "bitflags 2.11.1", ] [[package]] @@ -5970,12 +6324,42 @@ dependencies = [ "precomputed-hash", ] +[[package]] +name = "strsim" +version = "0.8.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8ea5119cdb4c55b55d432abb513a0429384878c15dde60cc77b1c99de1a95a6a" + [[package]] name = "strsim" version = "0.11.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "7da8b5736845d9f2fcb837ea5d9e2628564b3b043a70948a3f0b778838c5fb4f" +[[package]] +name = "structopt" +version = "0.3.26" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c6b5c64445ba8094a6ab0c3cd2ad323e07171012d9c98b0b15651daf1787a10" +dependencies = [ + "clap 2.34.0", + "lazy_static", + "structopt-derive", +] + +[[package]] +name = "structopt-derive" +version = "0.4.18" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dcb5ae327f9cc13b68763b5749770cb9e048a99bd9dfdfa58d0cf05d5f64afe0" +dependencies = [ + "heck 0.3.3", + "proc-macro-error", + "proc-macro2", + "quote", + "syn 1.0.109", +] + [[package]] name = "strum" version = "0.25.0" @@ -6043,6 +6427,18 @@ version = "2.6.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "13c2bddecc57b384dee18652358fb23172facb8a2c51ccc10d74c157bdea3292" +[[package]] +name = "sugar" +version = "0.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "616a397f08825a805c27d9a18afcd17d953aee58c1637d078cc1791e1e3e22c1" +dependencies = [ + "cute", + "maplit", + "min_max_macros", + "vec_box", +] + [[package]] name = "syn" version = "1.0.109" @@ -6118,7 +6514,7 @@ dependencies = [ "getrandom 0.4.2", "once_cell", "rustix", - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -6147,6 +6543,15 @@ version = "0.5.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8f50febec83f5ee1df3015341d8bd429f2d1cc62bcba7ea2076759d315084683" +[[package]] +name = "textwrap" +version = "0.11.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d326610f408c7a4eb6f51c37c330e496b08506c9457c9d34287ecc38809fb060" +dependencies = [ + "unicode-width 0.1.14", +] + [[package]] name = "thiserror" version = "1.0.69" @@ -6187,6 +6592,20 @@ dependencies = [ "syn 2.0.117", ] +[[package]] +name = "thread-priority" +version = "1.2.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cfe075d7053dae61ac5413a34ea7d4913b6e6207844fd726bdd858b37ff72bf5" +dependencies = [ + "bitflags 2.11.1", + "cfg-if", + "libc", + "log", + "rustversion", + "winapi", +] + [[package]] name = "time" version = "0.3.47" @@ -6302,9 +6721,9 @@ dependencies = [ [[package]] name = "tket-json-rs" -version = "0.8.2" +version = "0.8.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f94cdded1cb82aaf9e0c2508762753f72c60ce8c068853b8ecc6c1bcd21644b9" +checksum = "d411ed63c40c69f147fb2ecfae59bc9c8e15aee1f0d916fb07f37465a7a4b2e9" dependencies = [ "derive_more 2.1.1", "serde", @@ -6337,9 +6756,9 @@ dependencies = [ [[package]] name = "tokio" -version = "1.52.1" +version = "1.52.3" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b67dee974fe86fd92cc45b7a95fdd2f99a36a6d7b0d431a231178d3d670bbcc6" +checksum = "8fc7f01b389ac15039e4dc9531aa973a135d7a4135281b12d7c1bc79fd57fffe" dependencies = [ "bytes", "libc", @@ -6427,20 +6846,20 @@ dependencies = [ [[package]] name = "tower-http" -version = "0.6.8" +version = "0.6.10" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d4e6559d53cc268e5031cd8429d05415bc4cb4aefc4aa5d6cc35fbf5b924a1f8" +checksum = "68d6fdd9f81c2819c9a8b0e0cd91660e7746a8e6ea2ba7c6b2b057985f6bcb51" dependencies = [ - "bitflags", + "bitflags 2.11.1", "bytes", "futures-util", "http", "http-body", - "iri-string", "pin-project-lite", "tower", "tower-layer", "tower-service", + "url", ] [[package]] @@ -6506,9 +6925,9 @@ checksum = "bc7d623258602320d5c55d1bc22793b57daff0ec7efc270ea7d55ce1d5f5471c" [[package]] name = "typenum" -version = "1.19.0" +version = "1.20.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "562d481066bde0658276a35467c4af00bdc6ee726305698a55b86e61d7ad82bb" +checksum = "40ce102ab67701b8526c123c1bab5cbe42d7040ccfd0f64af1a385808d2f43de" [[package]] name = "typetag" @@ -6558,6 +6977,12 @@ version = "1.13.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9629274872b2bfaf8d66f5f15725007f635594914870f65218920345aa11aa8c" +[[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" @@ -6620,9 +7045,9 @@ checksum = "06abde3611657adf66d383f00b093d7faecc7fa57071cce2578660c9f1010821" [[package]] name = "uuid" -version = "1.22.0" +version = "1.23.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a68d3c8f01c0cfa54a75291d83601161799e4a89a39e0929f4b0354d88757a37" +checksum = "ddd74a9687298c6858e9b88ec8935ec45d22e8fd5e6394fa1bd4e99a87789c76" dependencies = [ "getrandom 0.4.2", "js-sys", @@ -6630,6 +7055,18 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "vec_box" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c5283fc94a1cd6b5199f707a4b4d7f885b9457d430d111c68a07d3e3d199fb2b" + +[[package]] +name = "vec_map" +version = "0.8.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f1bddf1187be692e79c5ffeab891132dfb0f236ed36a43c7ed39f1165ee20191" + [[package]] name = "version_check" version = "0.9.5" @@ -6672,11 +7109,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]] @@ -6685,14 +7122,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.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0bf938a0bacb0469e83c1e148908bd7d5a6010354cf4fb73279b7447422e3a89" +checksum = "49ace1d07c165b0864824eee619580c4689389afa9dc9ed3a4c75040d82e6790" dependencies = [ "cfg-if", "once_cell", @@ -6703,9 +7140,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-futures" -version = "0.4.68" +version = "0.4.71" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f371d383f2fb139252e0bfac3b81b265689bf45b6874af544ffa4c975ac1ebf8" +checksum = "96492d0d3ffba25305a7dc88720d250b1401d7edca02cc3bcd50633b424673b8" dependencies = [ "js-sys", "wasm-bindgen", @@ -6713,9 +7150,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "eeff24f84126c0ec2db7a449f0c2ec963c6a49efe0698c4242929da037ca28ed" +checksum = "8e68e6f4afd367a562002c05637acb8578ff2dea1943df76afb9e83d177c8578" dependencies = [ "quote", "wasm-bindgen-macro-support", @@ -6723,9 +7160,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-macro-support" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9d08065faf983b2b80a79fd87d8254c409281cf7de75fc4b773019824196c904" +checksum = "d95a9ec35c64b2a7cb35d3fead40c4238d0940c86d107136999567a4703259f2" dependencies = [ "bumpalo", "proc-macro2", @@ -6736,9 +7173,9 @@ dependencies = [ [[package]] name = "wasm-bindgen-shared" -version = "0.2.118" +version = "0.2.121" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5fd04d9e306f1907bd13c6361b5c6bfc7b3b3c095ed3f8a9246390f8dbdee129" +checksum = "c4e0100b01e9f0d03189a92b96772a1fb998639d981193d7dbab487302513441" dependencies = [ "unicode-ident", ] @@ -6755,22 +7192,22 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.245.1" +version = "0.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" +checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7" dependencies = [ "leb128fmt", - "wasmparser 0.245.1", + "wasmparser 0.246.2", ] [[package]] name = "wasm-encoder" -version = "0.246.2" +version = "0.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "61fb705ce81adde29d2a8e99d87995e39a6e927358c91398f374474746070ef7" +checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" dependencies = [ "leb128fmt", - "wasmparser 0.246.2", + "wasmparser 0.248.0", ] [[package]] @@ -6791,7 +7228,7 @@ version = "0.244.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.15.5", "indexmap 2.14.0", "semver", @@ -6799,11 +7236,11 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.245.1" +version = "0.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" +checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" dependencies = [ - "bitflags", + "bitflags 2.11.1", "hashbrown 0.16.1", "indexmap 2.14.0", "semver", @@ -6812,35 +7249,35 @@ dependencies = [ [[package]] name = "wasmparser" -version = "0.246.2" +version = "0.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" dependencies = [ - "bitflags", + "bitflags 2.11.1", "indexmap 2.14.0", "semver", ] [[package]] name = "wasmprinter" -version = "0.245.1" +version = "0.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" +checksum = "6e41f7493ba994b8a779430a4c25ff550fd5a40d291693af43a6ef48688f00e3" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.245.1", + "wasmparser 0.246.2", ] [[package]] name = "wasmtime" -version = "43.0.2" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "efb1ed5899dde98357cfdcf647a4614498798719793898245b4b34e663addabf" +checksum = "372db8bbad8ec962038101f75ab2c3ffcd18797d7d3ae877a58ab9873cd0c4bd" dependencies = [ "addr2line", "async-trait", - "bitflags", + "bitflags 2.11.1", "bumpalo", "cc", "cfg-if", @@ -6857,7 +7294,7 @@ dependencies = [ "serde_derive", "smallvec", "target-lexicon", - "wasmparser 0.245.1", + "wasmparser 0.246.2", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-cranelift", @@ -6872,11 +7309,12 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "43.0.2" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4172382dcc785c31d0e862c6780a18f5dd437914d22c4691351f965ef751c821" +checksum = "1e15aa0d1545e48d9b25ca604e9e27b4cd6d5886d30ac5787b57b3a2daf85b57" dependencies = [ "anyhow", + "cpp_demangle", "cranelift-bforest", "cranelift-bitset", "cranelift-entity", @@ -6886,22 +7324,23 @@ dependencies = [ "log", "object", "postcard", + "rustc-demangle", "serde", "serde_derive", "sha2 0.10.9", "smallvec", "target-lexicon", - "wasm-encoder 0.245.1", - "wasmparser 0.245.1", + "wasm-encoder 0.246.2", + "wasmparser 0.246.2", "wasmprinter", "wasmtime-internal-core", ] [[package]] name = "wasmtime-internal-core" -version = "43.0.2" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "9a3820b174f477d2a7083209d1ad5353fcdb11eaea434b2137b8681029460dd3" +checksum = "8f2c7fa6523647262bfb4095dbdf4087accefe525813e783f81a0c682f418ce4" dependencies = [ "hashbrown 0.16.1", "libm", @@ -6910,9 +7349,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "43.0.2" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d1679d205caf9766c6aa309d45bb3e7c634d7725e3164404df33824b9f7c4fb7" +checksum = "98c032f422e39061dfc43f32190c0a3526b04161ec4867f362958f3fe9d1fe29" dependencies = [ "cfg-if", "cranelift-codegen", @@ -6928,7 +7367,7 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.18", - "wasmparser 0.245.1", + "wasmparser 0.246.2", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-unwinder", @@ -6937,9 +7376,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "43.0.2" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1e505254058be5b0df458d670ee42d9eafe2349d04c1296e9dc01071dc20a85" +checksum = "d8dd76d80adf450cc260ba58f23c28030401930b19149695b1d121f7d621e791" dependencies = [ "cc", "cfg-if", @@ -6952,9 +7391,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "43.0.2" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1c2e05b345f1773e59c20e6ad7298fd6857cdea245023d88bb659c96d8f0ea72" +checksum = "ab453cc600b28ee5d3f9495aa6d4cb2c81eda40903e9287296b548fba8b2391d" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -6962,9 +7401,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "43.0.2" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b86701b234a4643e3f111869aa792b3a05a06e02d486ee9cb6c04dae16b52dab" +checksum = "6a1859e920871515d324fb9757c3e448d6ed1512ca6ccdff14b6e016505d6ada" dependencies = [ "cfg-if", "libc", @@ -6974,9 +7413,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "43.0.2" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f63558d801beb83dde9b336eb4ae049019aee26627926edb32cd119d7e4c83cd" +checksum = "f1dfe405bd6adb1386d935a30f16a236bd4ef0d3c383e7cbbab98d063c9d9b73" dependencies = [ "cfg-if", "cranelift-codegen", @@ -6987,9 +7426,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "43.0.2" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "737c4d956fc3a848541a064afb683dd2771132a6b125be5baaf95c4379aa47df" +checksum = "2a9b9165fc45d42c81edfe3e9cb458e58720594ad5db6553c4079ea041a4a581" dependencies = [ "proc-macro2", "quote", @@ -6998,22 +7437,22 @@ dependencies = [ [[package]] name = "wast" -version = "246.0.2" +version = "248.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "fe3fe8e3bf88ad96d031b4181ddbd64634b17cb0d06dfc3de589ef43591a9a62" +checksum = "acc54622ed5a5cddafcdf152043f9d4aed54d4a653d686b7dfe874809fca99d7" dependencies = [ "bumpalo", "leb128fmt", "memchr", - "unicode-width", - "wasm-encoder 0.246.2", + "unicode-width 0.2.2", + "wasm-encoder 0.248.0", ] [[package]] name = "wat" -version = "1.246.2" +version = "1.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4bd7fda1199b94fff395c2d19a153f05dbe7807630316fa9673367666fd2ad8c" +checksum = "d75cd9e510603909748e6ebab89f27cd04472c1d9d85a3c88a7a6fc51a1a7934" dependencies = [ "wast", ] @@ -7038,9 +7477,9 @@ checksum = "323f4da9523e9a669e1eaf9c6e763892769b1d38c623913647bfdc1532fe4549" [[package]] name = "web-sys" -version = "0.3.95" +version = "0.3.98" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f2dfbb17949fa2088e5d39408c48368947b86f7834484e87b73de55bc14d97d" +checksum = "4b572dff8bcf38bad0fa19729c89bb5748b2b9b1d8be70cf90df697e3a8f32aa" dependencies = [ "js-sys", "wasm-bindgen", @@ -7067,12 +7506,12 @@ dependencies = [ [[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 0.7.6", - "bitflags", + "bitflags 2.11.1", "bytemuck", "cfg-if", "cfg_aliases", @@ -7097,14 +7536,14 @@ 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 0.7.6", "bit-set 0.9.1", "bit-vec 0.9.1", - "bitflags", + "bitflags 2.11.1", "bytemuck", "cfg_aliases", "document-features", @@ -7130,42 +7569,42 @@ 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 0.7.6", "ash", "bit-set 0.9.1", - "bitflags", + "bitflags 2.11.1", "block2", "bytemuck", "cfg-if", @@ -7206,13 +7645,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", @@ -7220,11 +7660,11 @@ 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", + "bitflags 2.11.1", "bytemuck", "js-sys", "log", @@ -7244,9 +7684,9 @@ dependencies = [ [[package]] name = "wide" -version = "1.3.0" +version = "1.4.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "c9479f84a757f819cfab37295955906479181395de83add28f74975fde083141" +checksum = "9a7714cd0430a663154667c74da5d09325c2387695bee18b3f7f72825aa3693a" dependencies = [ "bytemuck", "safe_arch 1.0.0", @@ -7274,7 +7714,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.52.0", + "windows-sys 0.61.2", ] [[package]] @@ -7384,15 +7824,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" @@ -7420,21 +7851,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" @@ -7477,12 +7893,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" @@ -7495,12 +7905,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" @@ -7513,12 +7917,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" @@ -7543,12 +7941,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" @@ -7561,12 +7953,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" @@ -7579,12 +7965,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" @@ -7597,12 +7977,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" @@ -7617,9 +7991,9 @@ checksum = "d6bbff5f0aada427a1e5a6da5f1f98158182f26556f345ac9e04d36d0ebed650" [[package]] name = "winnow" -version = "1.0.1" +version = "1.0.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "09dac053f1cd375980747450bfc7250c264eaae0583872e845c0c7cd578872b5" +checksum = "2ee1708bef14716a11bae175f579062d4554d95be2c6829f518df847b7b3fdd0" dependencies = [ "memchr", ] @@ -7633,6 +8007,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" @@ -7682,7 +8062,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", - "bitflags", + "bitflags 2.11.1", "indexmap 2.14.0", "log", "serde", diff --git a/Cargo.toml b/Cargo.toml index ce3e865cd..5cdaae24b 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -70,7 +70,7 @@ tket-qsystem = { version = "0.23", default-features = false } hugr-core = "=0.25.6" # --- WebAssembly --- -wasmtime = { version = "43", default-features = false, features = [ +wasmtime = { version = "44", default-features = false, features = [ "cranelift", "runtime", "wat", @@ -151,12 +151,16 @@ assert_cmd = "2" criterion = "0.8" paste = "1" random_tester = "0.1" +proptest = "1" # --- ZX calculus --- quizx = "0.3" # --- Decoder libraries --- fusion-blossom = "0.2" +mwpf = { git = "https://github.com/yuewuo/mwpf", tag = "v0.2.12", default-features = false, features = [ + "f64_weight", +] } relay-bp = "0.2" # ndarray 0.16 for relay-bp compat (relay-bp uses ndarray 0.16, PECOS uses 0.17) ndarray-016 = { package = "ndarray", version = "0.16" } @@ -177,6 +181,8 @@ pecos-cuquantum-sys = { version = "0.2.0-dev.0", path = "crates/pecos-cuquantum- pecos-decoder-core = { version = "0.2.0-dev.0", path = "crates/pecos-decoder-core" } pecos-decoders = { version = "0.2.0-dev.0", path = "crates/pecos-decoders" } pecos-engines = { version = "0.2.0-dev.0", path = "crates/pecos-engines" } +pecos-eeg = { version = "0.2.0-dev.0", path = "exp/pecos-eeg" } +pecos-stab-tn = { version = "0.2.0-dev.0", path = "exp/pecos-stab-tn" } pecos-experimental = { version = "0.2.0-dev.0", path = "exp/pecos-experimental" } pecos-foreign = { version = "0.2.0-dev.0", path = "crates/pecos-foreign" } pecos-fusion-blossom = { version = "0.2.0-dev.0", path = "crates/pecos-fusion-blossom" } @@ -184,7 +190,9 @@ pecos-gpu-sims = { version = "0.2.0-dev.0", path = "crates/pecos-gpu-sims" } pecos-hugr = { version = "0.2.0-dev.0", path = "crates/pecos-hugr" } pecos-hugr-qis = { version = "0.2.0-dev.0", path = "crates/pecos-hugr-qis" } pecos-ldpc-decoders = { version = "0.2.0-dev.0", path = "crates/pecos-ldpc-decoders" } +pecos-lindblad = { version = "0.2.0-dev.0", path = "exp/pecos-lindblad" } pecos-llvm = { version = "0.2.0-dev.0", path = "crates/pecos-llvm" } +pecos-mwpf = { version = "0.2.0-dev.0", path = "crates/pecos-mwpf" } pecos-neo = { version = "0.2.0-dev.0", path = "exp/pecos-neo" } pecos-num = { version = "0.2.0-dev.0", path = "crates/pecos-num" } pecos-phir = { version = "0.2.0-dev.0", path = "crates/pecos-phir" } @@ -203,6 +211,7 @@ 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" } pecos-tesseract = { version = "0.2.0-dev.0", path = "crates/pecos-tesseract" } +pecos-uf-decoder = { version = "0.2.0-dev.0", path = "crates/pecos-uf-decoder" } pecos-wasm = { version = "0.2.0-dev.0", path = "crates/pecos-wasm" } pecos-zx = { version = "0.2.0-dev.0", path = "exp/pecos-zx" } @@ -220,6 +229,14 @@ lto = true # Link-time optimization (same as "fat") codegen-units = 1 # Single codegen unit for better optimization strip = true # Strip debug symbols for smaller binaries +# Profiling profile: release optimizations but keeps debug info for perf/samply. +# Use with: cargo build --profile profiling +# Or: maturin develop --profile profiling +[profile.profiling] +inherits = "release" +strip = false +debug = 1 # line tables only — minimal size impact + # Native profile: release + CPU-specific optimizations # Use with: cargo build --profile native # Build scripts detect this via PROFILE=native env var and add --march=native for C++ code @@ -238,4 +255,4 @@ multiple-crate-versions = "allow" # Physics/math code uses short, domain-standard variable names (sx/sz, x/z/r/s/p) similar-names = "allow" many-single-char-names = "allow" -too-many-lines = "allow" # ~114 hits across 11 crates -- sim algorithms, tests, benchmarks +too-many-lines = "allow" # ~114 hits across 11 crates -- sim algorithms, tests, benchmarks diff --git a/Justfile b/Justfile index 7033955a0..c05a2600f 100644 --- a/Justfile +++ b/Justfile @@ -195,7 +195,7 @@ test mode="release": (rstest mode) pytest # Fix formatting and linting issues (or: just lint check) [group('lint')] -lint mode="fix": +lint mode="fix": python-workspace-check #!/usr/bin/env bash set -euo pipefail # Detect CUDA: only use --all-features when CUDA toolkit is available @@ -245,6 +245,11 @@ lint mode="fix": check: cargo check --workspace --all-targets +# Check Python workspace metadata +[group('lint')] +python-workspace-check: + @uv run python scripts/check_python_workspace.py + # Run cargo clippy (CUDA-aware: uses --all-features only when CUDA is available) [group('lint')] clippy: diff --git a/README.md b/README.md index a1fa04841..d77865ba2 100644 --- a/README.md +++ b/README.md @@ -82,6 +82,7 @@ For OpenQASM, PHIR, or other formats, see the [documentation](#documentation). F For tutorials, API reference, and advanced features: - [Getting Started Guide](docs/user-guide/getting-started.md) — Installation, first simulation, next steps +- [PECOS Concepts](docs/user-guide/pecos-concepts.md) — Detectors, observables, tracked operators, gates, and noise - [Simulators Guide](docs/user-guide/simulators.md) — Choosing the right backend - [Noise Model Builders](docs/user-guide/noise-model-builders.md) — Adding realistic noise - [Decoders Guide](docs/user-guide/decoders.md) — Quantum error correction decoding diff --git a/crates/benchmarks/README.md b/crates/benchmarks/README.md index 7424d17bc..52c525056 100644 --- a/crates/benchmarks/README.md +++ b/crates/benchmarks/README.md @@ -22,3 +22,26 @@ Alternatively, run manually with: ```bash RUSTFLAGS="-C target-cpu=native" cargo bench -p benchmarks ``` + +## Fault Catalog Benchmarks + +The fault-catalog suite covers rotated surface-code memory circuits at +distances 3, 5, 7, 9, and 11. It measures structural catalog construction, +noise re-parameterization, raw-mechanism materialization, and noise-sweep +strategies. + +```bash +# Full fault-catalog suite +just bench native "" "fault_catalog/" + +# Structural construction only +just bench native "" "fault_catalog/from_circuit" + +# Compare direct rebuild, cloned parameterization, and mutable with_noise sweeps +just bench native "" "fault_catalog/noise_sweep" +``` + +For parameter sweeps that do not need to keep independent catalog snapshots, +prefer building one structural catalog and calling `with_noise()` for each +noise point. Use `parameterized()` when the code needs independent catalogs +alive at the same time. diff --git a/crates/benchmarks/benches/benchmarks.rs b/crates/benchmarks/benches/benchmarks.rs index d32dad430..d496fa89b 100644 --- a/crates/benchmarks/benches/benchmarks.rs +++ b/crates/benchmarks/benches/benchmarks.rs @@ -21,6 +21,7 @@ mod modules { pub mod dem_builder; pub mod dem_sampler; pub mod dod_statevec; + pub mod fault_catalog; pub mod quizx_eval; pub mod stab_vec; // TODO: pub mod hadamard_ops; @@ -44,6 +45,7 @@ mod modules { pub mod stabilizer_sims; pub mod state_vec_sims; pub mod surface_code; + pub mod tick_circuit_layout; pub mod trig; } @@ -57,9 +59,9 @@ use modules::sparse_stab_vs_cpp; use modules::stab_mps_vs_stab_vec; use modules::{ allocation_overhead, cpu_stabilizer_comparison, dem_builder, dem_sampler, dod_statevec, - measurement_sampling, native_statevec_comparison, noise_models, pecos_neo_comparison, - quizx_eval, rng, set_ops, sparse_stab_w_vs_y, sparse_state_vec, stab_vec, stabilizer_sims, - state_vec_sims, surface_code, trig, + fault_catalog, measurement_sampling, native_statevec_comparison, noise_models, + pecos_neo_comparison, quizx_eval, rng, set_ops, sparse_stab_w_vs_y, sparse_state_vec, stab_vec, + stabilizer_sims, state_vec_sims, surface_code, tick_circuit_layout, trig, }; fn all_benchmarks(c: &mut Criterion) { @@ -72,6 +74,7 @@ fn all_benchmarks(c: &mut Criterion) { dem_builder::benchmarks(c); dem_sampler::benchmarks(c); dod_statevec::benchmarks(c); + fault_catalog::benchmarks(c); #[cfg(feature = "gpu-sims")] gpu_influence_sampler::benchmarks(c); measurement_sampling::benchmarks(c); @@ -87,6 +90,7 @@ fn all_benchmarks(c: &mut Criterion) { sparse_stab_vs_cpp::benchmarks(c); sparse_stab_w_vs_y::benchmarks(c); surface_code::benchmarks(c); + tick_circuit_layout::benchmarks(c); #[cfg(feature = "stab-tn")] stab_mps_vs_stab_vec::benchmarks(c); trig::benchmarks(c); diff --git a/crates/benchmarks/benches/modules/dem_sampler.rs b/crates/benchmarks/benches/modules/dem_sampler.rs index b46a2feac..42353ae76 100644 --- a/crates/benchmarks/benches/modules/dem_sampler.rs +++ b/crates/benchmarks/benches/modules/dem_sampler.rs @@ -11,30 +11,17 @@ // the License. //! DEM Sampler benchmarks for threshold estimation. -//! -//! These benchmarks measure the performance of the DEM-based sampling -//! infrastructure used for fast error threshold estimation. -//! -//! # Benchmarks -//! -//! - **DEM Sampler - Original vs Columnar**: Compare row-major vs column-major sampling -//! - **DEM Sampler - Statistics**: Compare statistics-only methods -//! - **DEM Sampler - Scaling**: How different methods scale with DEM size - -use std::str::FromStr; use criterion::{BenchmarkId, Criterion, Throughput, measurement::Measurement}; -use pecos_qec::fault_tolerance::dem_builder::{DemSamplerBuilder, ParsedDem}; +use pecos_qec::fault_tolerance::dem_builder::DemSamplerBuilder; use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; use pecos_quantum::DagCircuit; use pecos_random::PecosRng; use std::hint::black_box; pub fn benchmarks(c: &mut Criterion) { - bench_sampler_comparison(c); - bench_statistics_comparison(c); - bench_optimization_comparison(c); - bench_parsed_dem_sampling(c); + bench_sampler(c); + bench_statistics(c); } /// Create a realistic DEM sampler from a surface-code-like circuit. @@ -45,48 +32,40 @@ fn create_surface_code_sampler( // Create a simplified surface code circuit let num_data = distance * distance; let num_ancilla = num_data - 1; + let total_qubits = num_data + num_ancilla; let mut dag = DagCircuit::new(); - // Initialize data qubits - for q in 0..num_data { - dag.pz(&[q]); - dag.h(&[q]); - } + // Prep all qubits + let all_qubits: Vec = (0..total_qubits).collect(); + dag.pz(&all_qubits); // Syndrome extraction rounds - for _round in 0..rounds { - // Initialize ancillas - for a in 0..num_ancilla { - dag.pz(&[num_data + a]); - } - - // Entangle ancillas with data (simplified pattern) + let ancilla_qubits: Vec = (num_data..total_qubits).collect(); + for _ in 0..rounds { + dag.h(&ancilla_qubits); for a in 0..num_ancilla { - let ancilla = num_data + a; - let d1 = a % num_data; - let d2 = (a + 1) % num_data; - dag.cx(&[(ancilla, d1)]); - dag.cx(&[(ancilla, d2)]); - } - - // Measure ancillas - for a in 0..num_ancilla { - dag.mz(&[num_data + a]); + let data_q = a.min(num_data - 1); + dag.cx(&[(data_q, num_data + a)]); } + dag.h(&ancilla_qubits); + dag.mz(&ancilla_qubits); + dag.pz(&ancilla_qubits); } - // Build influence map and sampler + // Measure data qubits + let data_qubits: Vec = (0..num_data).collect(); + dag.mz(&data_qubits); + + // Build influence map let analyzer = DagFaultAnalyzer::new(&dag); let influence_map = analyzer.build_influence_map(); - // Create detector definitions (one per ancilla measurement) - let num_measurements = num_ancilla * rounds; + // Build DEM sampler with simple detectors + let num_measurements = num_ancilla * rounds + num_data; let mut detector_records = Vec::new(); for i in 0..num_measurements.min(50) { - // Limit to 50 detectors for benchmark #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)] - // i < 50, fits in i32 detector_records.push(vec![-(i as i32 + 1)]); } @@ -95,48 +74,31 @@ fn create_surface_code_sampler( .with_detector_records(detector_records) .with_observable_records(vec![]) .build() + .expect("Failed to build DEM sampler") } -/// Benchmark comparing original row-major vs columnar sampling. -fn bench_sampler_comparison(c: &mut Criterion) { - let mut group = c.benchmark_group("DEM Sampler - Original vs Columnar"); +/// Benchmark sampling with different circuit sizes. +fn bench_sampler(c: &mut Criterion) { + let mut group = c.benchmark_group("DEM Sampler - Batch"); - // Test with different circuit sizes for (distance, rounds) in [(3, 2), (5, 3)] { let sampler = create_surface_code_sampler(distance, rounds); let num_mechanisms = sampler.num_mechanisms(); let num_detectors = sampler.num_detectors(); - // Test different shot counts for shots in [1_000, 10_000, 100_000] { let label = format!("d{distance}_r{rounds}_{shots}"); - group.throughput(Throughput::Elements((num_mechanisms * shots) as u64)); - // Original row-major sampling - group.bench_with_input(BenchmarkId::new("row_major", &label), &(), |b, ()| { + group.bench_with_input(BenchmarkId::new("sample_batch", &label), &(), |b, ()| { let mut rng = PecosRng::seed_from_u64(42); b.iter(|| { let result = sampler.sample_batch(shots, &mut rng); black_box(result) }); }); - - // Columnar sampling (accurate - one random per shot per mechanism) - group.bench_with_input( - BenchmarkId::new("columnar_accurate", &label), - &(), - |b, ()| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = sampler.sample_batch_columnar_accurate(shots, &mut rng); - black_box(result) - }); - }, - ); } - // Print info println!( " d={distance} r={rounds}: {num_mechanisms} mechanisms, {num_detectors} detectors" ); @@ -145,261 +107,29 @@ fn bench_sampler_comparison(c: &mut Criterion) { group.finish(); } -/// Benchmark comparing statistics methods. -fn bench_statistics_comparison(c: &mut Criterion) { +/// Benchmark statistics methods. +fn bench_statistics(c: &mut Criterion) { let mut group = c.benchmark_group("DEM Sampler - Statistics"); let sampler = create_surface_code_sampler(5, 3); let num_mechanisms = sampler.num_mechanisms(); - for shots in [10_000, 100_000, 1_000_000] { + for shots in [10_000, 100_000] { let label = format!("{shots}shots"); - group.throughput(Throughput::Elements((num_mechanisms * shots) as u64)); - // Original statistics (row-major) group.bench_with_input( - BenchmarkId::new("row_major", &label), + BenchmarkId::new("sample_statistics", &label), &shots, |b, &shots| { let mut rng = PecosRng::seed_from_u64(42); b.iter(|| { - let result = sampler.sample_statistics_row_major(shots, &mut rng); + let result = sampler.sample_statistics_with_rng(shots, &mut rng); black_box(result) }); }, ); - - // Columnar statistics - group.bench_with_input(BenchmarkId::new("columnar", &label), &shots, |b, &shots| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = sampler.sample_statistics_columnar(shots, &mut rng); - black_box(result) - }); - }); - } - - group.finish(); -} - -/// Benchmark comparing all optimization methods. -fn bench_optimization_comparison(c: &mut Criterion) { - let mut group = c.benchmark_group("DEM Sampler - Optimizations"); - - // Use a realistic surface code sampler - let sampler = create_surface_code_sampler(5, 3); - let num_mechanisms = sampler.num_mechanisms(); - - let shots = 100_000; - group.throughput(Throughput::Elements((num_mechanisms * shots) as u64)); - - // Baseline: current columnar_accurate - group.bench_function("columnar_accurate", |b| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = sampler.sample_batch_columnar_accurate(shots, &mut rng); - black_box(result) - }); - }); - - // SIMD u64x4 version - group.bench_function("simd_u64x4", |b| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = sampler.sample_batch_columnar_simd(shots, &mut rng); - black_box(result) - }); - }); - - // Geometric skip version (fastest for low error rates) - group.bench_function("geometric", |b| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = sampler.sample_batch_columnar_geometric(shots, &mut rng); - black_box(result) - }); - }); - - group.finish(); - - // Also compare statistics methods - let mut stats_group = c.benchmark_group("DEM Sampler - Stats Optimizations"); - stats_group.throughput(Throughput::Elements((num_mechanisms * shots) as u64)); - - stats_group.bench_function("stats_columnar", |b| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = sampler.sample_statistics_columnar(shots, &mut rng); - black_box(result) - }); - }); - - stats_group.bench_function("stats_simd", |b| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = sampler.sample_statistics_simd(shots, &mut rng); - black_box(result) - }); - }); - - stats_group.bench_function("stats_geometric", |b| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = sampler.sample_statistics_geometric(shots, &mut rng); - black_box(result) - }); - }); - - stats_group.bench_function("stats_auto", |b| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = sampler.sample_statistics_with_rng(shots, &mut rng); - black_box(result) - }); - }); - - stats_group.bench_function("stats_parallel", |b| { - b.iter(|| { - let result = sampler.sample_statistics_parallel(shots, 42); - black_box(result) - }); - }); - - stats_group.finish(); - - // Benchmark parallel scaling with larger DEM - bench_parallel_scaling(c); -} - -/// Benchmark parallel scaling with different DEM sizes. -fn bench_parallel_scaling(c: &mut Criterion) { - let mut group = c.benchmark_group("DEM Sampler - Parallel Scaling"); - - let shots = 100_000; - - // Test with different circuit sizes - for (distance, rounds) in [(3, 2), (5, 3), (7, 5)] { - let sampler = create_surface_code_sampler(distance, rounds); - let num_mechanisms = sampler.num_mechanisms(); - let label = format!("d{distance}_r{rounds}_{num_mechanisms}mech"); - - group.throughput(Throughput::Elements((num_mechanisms * shots) as u64)); - - // Sequential geometric (baseline) - group.bench_with_input(BenchmarkId::new("sequential", &label), &(), |b, ()| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = sampler.sample_statistics_geometric(shots, &mut rng); - black_box(result) - }); - }); - - // Parallel - group.bench_with_input(BenchmarkId::new("parallel", &label), &(), |b, ()| { - b.iter(|| { - let result = sampler.sample_statistics_parallel(shots, 42); - black_box(result) - }); - }); - - // Auto (should pick geometric for low p) - group.bench_with_input(BenchmarkId::new("auto", &label), &(), |b, ()| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = sampler.sample_statistics_with_rng(shots, &mut rng); - black_box(result) - }); - }); } group.finish(); } - -/// Create a synthetic DEM string for benchmarking. -fn create_synthetic_dem(num_mechanisms: usize, num_detectors: usize, prob: f64) -> String { - use std::fmt::Write; - - let mut dem = String::new(); - - for i in 0..num_detectors { - writeln!(dem, "detector({i}, 0, 0) D{i}").unwrap(); - } - - for i in 0..num_mechanisms { - let d1 = i % num_detectors; - let d2 = (i + 1) % num_detectors; - let d3 = (i + 2) % num_detectors; - - match i % 3 { - 0 => writeln!(dem, "error({prob}) D{d1}").unwrap(), - 1 => writeln!(dem, "error({prob}) D{d1} D{d2}").unwrap(), - _ => writeln!(dem, "error({prob}) D{d1} D{d2} D{d3}").unwrap(), - } - } - - dem -} - -/// Benchmark `ParsedDem` sampling (used by equivalence testing). -fn bench_parsed_dem_sampling(c: &mut Criterion) { - let mut group = c.benchmark_group("ParsedDem - Sampling"); - - let medium_dem = create_synthetic_dem(50, 24, 0.01); - let complex_dem = create_synthetic_dem(200, 96, 0.01); - - let dems: [(&str, &str); 3] = [ - ( - "simple", - "error(0.01) D0\nerror(0.01) D1\nerror(0.01) D0 D1", - ), - ("medium", &medium_dem), - ("complex", &complex_dem), - ]; - - let shots = 50_000; - - for (name, dem_str) in &dems { - let dem = ParsedDem::from_str(dem_str).expect("failed to parse DEM"); - let num_mechanisms = dem.mechanisms.len(); - - group.throughput(Throughput::Elements((num_mechanisms * shots) as u64)); - - group.bench_with_input(BenchmarkId::new("sample_batch", *name), &(), |b, ()| { - let mut rng = PecosRng::seed_from_u64(42); - b.iter(|| { - let result = dem.sample_batch(shots, &mut rng); - black_box(result) - }); - }); - } - - group.finish(); -} - -#[cfg(test)] -mod tests { - - #[test] - fn test_sampler_creation() { - let sampler = create_surface_code_sampler(3, 1); - assert!(sampler.num_mechanisms() > 0); - } - - #[test] - fn test_columnar_matches_row_major() { - let sampler = create_surface_code_sampler(3, 1); - - // Sample with row-major - let mut rng1 = PecosRng::seed_from_u64(42); - let stats1 = sampler.sample_statistics(1000, &mut rng1); - - // Sample with columnar - let mut rng2 = PecosRng::seed_from_u64(42); - let stats2 = sampler.sample_statistics_columnar(1000, &mut rng2); - - // Statistics should be similar (not exact due to different RNG consumption order) - // Just verify both produce reasonable results - assert!(stats1.total_shots == stats2.total_shots); - } -} diff --git a/crates/benchmarks/benches/modules/fault_catalog.rs b/crates/benchmarks/benches/modules/fault_catalog.rs new file mode 100644 index 000000000..58f428eac --- /dev/null +++ b/crates/benchmarks/benches/modules/fault_catalog.rs @@ -0,0 +1,476 @@ +// 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. + +//! Parameterized fault-catalog benchmarks. +//! +//! These benchmarks cover the Rust-side sweep path before doing packed +//! performance work: +//! - structural catalog construction from a `TickCircuit`, +//! - applying a concrete noise point with `with_noise`, +//! - projecting the richer catalog into raw-measurement mechanisms, and +//! - amortized noise sweeps compared with direct concrete catalog builds. + +use criterion::{BatchSize, BenchmarkId, Criterion, Throughput, measurement::Measurement}; +use pecos_qec::SurfaceCode; +use pecos_qec::fault_tolerance::fault_sampler::{ + FaultCatalog, StochasticNoiseParams, build_fault_catalog, build_fault_table, +}; +use pecos_quantum::{Attribute, TickCircuit, TickMeasRef}; +use std::hint::black_box; + +const DISTANCES: &[usize] = &[3, 5, 7, 9, 11]; +const SWEEP_NOISES: &[StochasticNoiseParams] = &[ + StochasticNoiseParams { + p1: 0.00005, + p2: 0.0005, + p_meas: 0.0005, + p_prep: 0.0005, + }, + StochasticNoiseParams { + p1: 0.0001, + p2: 0.001, + p_meas: 0.001, + p_prep: 0.001, + }, + StochasticNoiseParams { + p1: 0.0002, + p2: 0.002, + p_meas: 0.002, + p_prep: 0.002, + }, + StochasticNoiseParams { + p1: 0.0005, + p2: 0.005, + p_meas: 0.005, + p_prep: 0.005, + }, +]; + +#[derive(Debug)] +struct MemoryCircuit { + circuit: TickCircuit, + distance: usize, + rounds: usize, + num_measurements: usize, + num_detectors: usize, + num_observables: usize, +} + +#[derive(Debug)] +struct CatalogShape { + locations: usize, + alternatives: usize, +} + +pub fn benchmarks(c: &mut Criterion) { + bench_from_circuit(c); + bench_with_noise(c); + bench_to_mechanisms(c); + bench_noise_sweeps(c); +} + +fn bench_from_circuit(c: &mut Criterion) { + let mut group = c.benchmark_group("fault_catalog/from_circuit"); + + for memory in surface_memory_circuits() { + let shape = catalog_shape(&memory.circuit); + group.throughput(Throughput::Elements(as_u64(shape.locations))); + group.bench_with_input(bench_id(&memory, &shape), &memory, |b, memory| { + b.iter(|| { + black_box(FaultCatalog::from_circuit(black_box(&memory.circuit))) + .expect("surface memory circuit should be supported") + }); + }); + } + + group.finish(); +} + +fn bench_with_noise(c: &mut Criterion) { + let mut group = c.benchmark_group("fault_catalog/with_noise"); + let noise = representative_noise(); + + for memory in surface_memory_circuits() { + let shape = catalog_shape(&memory.circuit); + group.throughput(Throughput::Elements(as_u64(shape.locations))); + group.bench_with_input(bench_id(&memory, &shape), &memory, |b, memory| { + b.iter_batched( + || { + FaultCatalog::from_circuit(&memory.circuit) + .expect("surface memory circuit should be supported") + }, + |mut catalog| { + catalog.with_noise(black_box(&noise)); + black_box(catalog) + }, + BatchSize::SmallInput, + ); + }); + } + + group.finish(); +} + +fn bench_to_mechanisms(c: &mut Criterion) { + let mut group = c.benchmark_group("fault_catalog/to_mechanisms"); + let noise = representative_noise(); + + for memory in surface_memory_circuits() { + let shape = catalog_shape(&memory.circuit); + group.throughput(Throughput::Elements(as_u64(shape.alternatives))); + group.bench_with_input(bench_id(&memory, &shape), &memory, |b, memory| { + b.iter_batched( + || { + let mut catalog = FaultCatalog::from_circuit(&memory.circuit) + .expect("surface memory circuit should be supported"); + catalog.with_noise(&noise); + catalog + }, + |catalog| black_box(catalog.to_mechanisms()), + BatchSize::SmallInput, + ); + }); + } + + group.finish(); +} + +fn bench_noise_sweeps(c: &mut Criterion) { + let mut group = c.benchmark_group("fault_catalog/noise_sweep"); + + for memory in surface_memory_circuits() { + let shape = catalog_shape(&memory.circuit); + let id = bench_label(&memory, &shape); + group.throughput(Throughput::Elements(as_u64( + shape.locations * SWEEP_NOISES.len(), + ))); + + group.bench_with_input( + BenchmarkId::new("direct_catalog_sweep", id.clone()), + &memory, + |b, memory| { + b.iter(|| { + let mut total_locations = 0usize; + let mut total_alternatives = 0usize; + for noise in SWEEP_NOISES { + let catalog = build_fault_catalog(black_box(&memory.circuit), noise) + .expect("surface memory circuit should be supported"); + total_locations += catalog.locations.len(); + total_alternatives += count_alternatives(&catalog); + } + black_box((total_locations, total_alternatives)) + }); + }, + ); + + group.bench_with_input( + BenchmarkId::new("parameterized_catalog_sweep", id.clone()), + &memory, + |b, memory| { + b.iter(|| { + let structural = FaultCatalog::from_circuit(black_box(&memory.circuit)) + .expect("surface memory circuit should be supported"); + let mut total_locations = 0usize; + let mut total_alternatives = 0usize; + for noise in SWEEP_NOISES { + let catalog = structural.parameterized(noise); + total_locations += catalog.locations.len(); + total_alternatives += count_alternatives(&catalog); + } + black_box((total_locations, total_alternatives)) + }); + }, + ); + + group.bench_with_input( + BenchmarkId::new("mutable_catalog_sweep", id.clone()), + &memory, + |b, memory| { + b.iter(|| { + let mut catalog = FaultCatalog::from_circuit(black_box(&memory.circuit)) + .expect("surface memory circuit should be supported"); + let mut total_locations = 0usize; + let mut total_alternatives = 0usize; + for noise in SWEEP_NOISES { + catalog.with_noise(black_box(noise)); + total_locations += catalog.locations.len(); + total_alternatives += count_alternatives(&catalog); + } + black_box((total_locations, total_alternatives)) + }); + }, + ); + + group.bench_with_input( + BenchmarkId::new("direct_raw_mechanism_sweep", id.clone()), + &memory, + |b, memory| { + b.iter(|| { + let mut total_mechanisms = 0usize; + let mut total_alternatives = 0usize; + for noise in SWEEP_NOISES { + let mechanisms = build_fault_table(black_box(&memory.circuit), noise) + .expect("surface memory circuit should be supported"); + total_mechanisms += mechanisms.len(); + total_alternatives += mechanisms + .iter() + .map(|mechanism| mechanism.alternatives.len()) + .sum::(); + } + black_box((total_mechanisms, total_alternatives)) + }); + }, + ); + + group.bench_with_input( + BenchmarkId::new("parameterized_raw_mechanism_sweep", id.clone()), + &memory, + |b, memory| { + b.iter(|| { + let structural = FaultCatalog::from_circuit(black_box(&memory.circuit)) + .expect("surface memory circuit should be supported"); + let mut total_mechanisms = 0usize; + let mut total_alternatives = 0usize; + for noise in SWEEP_NOISES { + let catalog = structural.parameterized(noise); + let mechanisms = catalog.to_mechanisms(); + total_mechanisms += mechanisms.len(); + total_alternatives += mechanisms + .iter() + .map(|mechanism| mechanism.alternatives.len()) + .sum::(); + } + black_box((total_mechanisms, total_alternatives)) + }); + }, + ); + + group.bench_with_input( + BenchmarkId::new("mutable_raw_mechanism_sweep", id), + &memory, + |b, memory| { + b.iter(|| { + let mut catalog = FaultCatalog::from_circuit(black_box(&memory.circuit)) + .expect("surface memory circuit should be supported"); + let mut total_mechanisms = 0usize; + let mut total_alternatives = 0usize; + for noise in SWEEP_NOISES { + catalog.with_noise(black_box(noise)); + let mechanisms = catalog.to_mechanisms(); + total_mechanisms += mechanisms.len(); + total_alternatives += mechanisms + .iter() + .map(|mechanism| mechanism.alternatives.len()) + .sum::(); + } + black_box((total_mechanisms, total_alternatives)) + }); + }, + ); + } + + group.finish(); +} + +fn surface_memory_circuits() -> Vec { + DISTANCES + .iter() + .map(|&distance| { + build_rotated_z_memory_circuit(distance, distance) + .expect("surface memory circuit should build") + }) + .collect() +} + +fn representative_noise() -> StochasticNoiseParams { + StochasticNoiseParams { + p1: 0.0001, + p2: 0.001, + p_meas: 0.001, + p_prep: 0.001, + } +} + +fn bench_id(memory: &MemoryCircuit, shape: &CatalogShape) -> BenchmarkId { + BenchmarkId::from_parameter(bench_label(memory, shape)) +} + +fn bench_label(memory: &MemoryCircuit, shape: &CatalogShape) -> String { + format!( + "d{}_r{}_m{}_det{}_obs{}_loc{}_alt{}", + memory.distance, + memory.rounds, + memory.num_measurements, + memory.num_detectors, + memory.num_observables, + shape.locations, + shape.alternatives, + ) +} + +fn catalog_shape(circuit: &TickCircuit) -> CatalogShape { + let catalog = + FaultCatalog::from_circuit(circuit).expect("surface memory circuit should be supported"); + CatalogShape { + locations: catalog.locations.len(), + alternatives: count_alternatives(&catalog), + } +} + +fn count_alternatives(catalog: &FaultCatalog) -> usize { + catalog + .locations + .iter() + .map(|location| location.faults.len()) + .sum() +} + +fn build_rotated_z_memory_circuit(distance: usize, rounds: usize) -> Result { + let code = SurfaceCode::rotated(distance)?; + let num_data = code.num_data_qubits(); + let x_ancilla_offset = num_data; + let z_ancilla_offset = x_ancilla_offset + code.num_x_stabilizers(); + + let x_ancilla = |idx: usize| x_ancilla_offset + idx; + let z_ancilla = |idx: usize| z_ancilla_offset + idx; + + let mut circuit = TickCircuit::new(); + let data_qubits: Vec = (0..num_data).collect(); + circuit.tick().pz(&data_qubits); + + let mut x_round_measurements: Vec> = Vec::with_capacity(rounds); + let mut z_round_measurements: Vec> = Vec::with_capacity(rounds); + + for _round in 0..rounds { + let x_ancillas: Vec = (0..code.num_x_stabilizers()).map(x_ancilla).collect(); + let z_ancillas: Vec = (0..code.num_z_stabilizers()).map(z_ancilla).collect(); + + circuit.tick().pz(&x_ancillas); + circuit.tick().pz(&z_ancillas); + circuit.tick().h(&x_ancillas); + + for check in code.x_stabilizers() { + let ancilla = x_ancilla(check.index); + for data in check.qubits() { + circuit.tick().cx(&[(ancilla, data)]); + } + } + + for check in code.z_stabilizers() { + let ancilla = z_ancilla(check.index); + for data in check.qubits() { + circuit.tick().cx(&[(data, ancilla)]); + } + } + + circuit.tick().h(&x_ancillas); + + let x_refs = circuit.tick().mz(&x_ancillas); + let z_refs = circuit.tick().mz(&z_ancillas); + x_round_measurements.push(x_refs); + z_round_measurements.push(z_refs); + } + + let final_data_measurements = circuit.tick().mz(&data_qubits); + let num_measurements = circuit.num_measurements(); + + let mut detectors: Vec> = Vec::new(); + + for &meas_ref in z_round_measurements[0] + .iter() + .take(code.num_z_stabilizers()) + { + detectors.push(relative_records(num_measurements, &[meas_ref])); + } + + for round in 1..rounds { + for (¤t, &previous) in x_round_measurements[round] + .iter() + .zip(x_round_measurements[round - 1].iter()) + .take(code.num_x_stabilizers()) + { + detectors.push(relative_records(num_measurements, &[current, previous])); + } + for (¤t, &previous) in z_round_measurements[round] + .iter() + .zip(z_round_measurements[round - 1].iter()) + .take(code.num_z_stabilizers()) + { + detectors.push(relative_records(num_measurements, &[current, previous])); + } + } + + let last_round = rounds - 1; + for check in code.z_stabilizers() { + let mut refs = vec![z_round_measurements[last_round][check.index]]; + refs.extend( + check + .qubits() + .into_iter() + .map(|q| final_data_measurements[q]), + ); + detectors.push(relative_records(num_measurements, &refs)); + } + + let logical_z_refs: Vec = code + .logical_z() + .data_qubits + .iter() + .map(|&q| final_data_measurements[q]) + .collect(); + let observables = vec![relative_records(num_measurements, &logical_z_refs)]; + + circuit.set_meta( + "num_measurements", + Attribute::String(num_measurements.to_string()), + ); + circuit.set_meta("detectors", Attribute::String(records_json(&detectors))); + circuit.set_meta("observables", Attribute::String(records_json(&observables))); + + Ok(MemoryCircuit { + circuit, + distance, + rounds, + num_measurements, + num_detectors: detectors.len(), + num_observables: observables.len(), + }) +} + +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(|meas_ref| { + i32::try_from(meas_ref.record_idx).expect("measurement record index fits in i32") + - num_measurements + }) + .collect() +} + +fn records_json(records: &[Vec]) -> String { + let entries: Vec = records + .iter() + .map(|records| { + let values = records + .iter() + .map(i32::to_string) + .collect::>() + .join(","); + format!(r#"{{"records":[{values}]}}"#) + }) + .collect(); + format!("[{}]", entries.join(",")) +} + +fn as_u64(value: usize) -> u64 { + u64::try_from(value).expect("benchmark size fits in u64") +} diff --git a/crates/benchmarks/benches/modules/gpu_influence_sampler.rs b/crates/benchmarks/benches/modules/gpu_influence_sampler.rs index ecb305548..27f218c6e 100644 --- a/crates/benchmarks/benches/modules/gpu_influence_sampler.rs +++ b/crates/benchmarks/benches/modules/gpu_influence_sampler.rs @@ -20,7 +20,7 @@ use criterion::{BenchmarkId, Criterion, Throughput, measurement::Measurement}; use pecos_gpu_sims::{GpuInfluenceMapData, GpuInfluenceSampler}; -use pecos_qec::fault_tolerance::noisy_sampler::{NoisySampler, UniformNoiseModel}; +use pecos_qec::fault_tolerance::dem_builder::DemSampler; use pecos_qec::fault_tolerance::{DagFaultInfluenceMap, InfluenceBuilder}; use pecos_quantum::DagCircuit; use std::hint::black_box; @@ -105,31 +105,44 @@ fn build_influence_maps( circuit: &DagCircuit, num_data: usize, ) -> (DagFaultInfluenceMap, GpuInfluenceMapData) { - let logical_qubits: Vec = (0..num_data).collect(); - let builder = InfluenceBuilder::new(circuit).with_logical_z(logical_qubits); + let tracked_pauli_qubits: Vec = (0..num_data).collect(); + let builder = InfluenceBuilder::new(circuit).with_z(&tracked_pauli_qubits); let influence_map = builder.build(); let ( num_loc, num_det, - num_log, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ) = influence_map.export_csr(); let gpu_map = GpuInfluenceMapData::from_csr( - num_loc, num_det, num_log, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, - det_data_z, log_off_x, log_data_x, log_off_y, log_data_y, log_off_z, log_data_z, + num_loc, + num_det, + num_dem_outputs, + det_off_x, + det_data_x, + det_off_y, + det_data_y, + det_off_z, + det_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ); (influence_map, gpu_map) @@ -155,11 +168,11 @@ fn bench_cpu_vs_gpu_surface_codes(c: &mut Criterion) { group.throughput(Throughput::Elements(u64::from(num_shots))); // CPU benchmark - let noise = UniformNoiseModel::depolarizing(p_error); - let mut cpu_sampler = NoisySampler::new(&cpu_map, noise, seed); + let probs = vec![p_error; cpu_map.locations.len()]; + let cpu_sampler = DemSampler::from_influence_map(&cpu_map, &probs); group.bench_with_input(BenchmarkId::new("CPU", &label), &(), |b, ()| { - b.iter(|| black_box(cpu_sampler.sample(num_shots as usize))); + b.iter(|| black_box(cpu_sampler.sample_statistics(num_shots as usize, seed))); }); // GPU benchmark @@ -196,11 +209,11 @@ fn bench_gpu_sampler_shot_scaling(c: &mut Criterion) { group.throughput(Throughput::Elements(u64::from(num_shots))); // CPU benchmark - let noise = UniformNoiseModel::depolarizing(p_error); - let mut cpu_sampler = NoisySampler::new(&cpu_map, noise, seed); + let probs = vec![p_error; cpu_map.locations.len()]; + let cpu_sampler = DemSampler::from_influence_map(&cpu_map, &probs); group.bench_with_input(BenchmarkId::new("CPU", &label), &num_shots, |b, &shots| { - b.iter(|| black_box(cpu_sampler.sample(shots as usize))); + b.iter(|| black_box(cpu_sampler.sample_statistics(shots as usize, seed))); }); // GPU benchmark diff --git a/crates/benchmarks/benches/modules/tick_circuit_layout.rs b/crates/benchmarks/benches/modules/tick_circuit_layout.rs new file mode 100644 index 000000000..cf020824c --- /dev/null +++ b/crates/benchmarks/benches/modules/tick_circuit_layout.rs @@ -0,0 +1,314 @@ +// 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. + +//! `TickCircuit` batched layout benchmarks. +//! +//! These benchmarks measure the current batched `TickCircuit` access patterns: +//! - direct `TickCircuit` traversal, +//! - explicit batched `TickCircuit` traversal, and +//! - direct vs `CircuitExecutor` simulator execution. + +use criterion::{BenchmarkId, Criterion, Throughput, measurement::Measurement}; +use pecos_core::gate_type::GateType; +use pecos_quantum::{Gate, QubitId, TickCircuit}; +use pecos_simulators::{CircuitExecutor, CliffordGateable, SparseStab}; +use std::hint::black_box; + +const DISTANCES: &[usize] = &[3, 5, 7, 9, 11]; +const AMORTIZED_SHOTS: usize = 64; + +#[derive(Clone)] +struct LayoutSpec { + label: String, + num_qubits: usize, + gate_count: usize, + tick_circuit: TickCircuit, +} + +pub fn benchmarks(c: &mut Criterion) { + let specs = DISTANCES + .iter() + .map(|&distance| { + let rounds = distance; + let tick_circuit = build_surface_like_tick_circuit(distance, rounds); + let num_qubits = surface_like_num_qubits(distance); + let gate_count = tick_circuit.gate_count(); + LayoutSpec { + label: format!("d{distance}_r{rounds}"), + num_qubits, + gate_count, + tick_circuit, + } + }) + .collect::>(); + + bench_traversal(c, &specs); + bench_execution(c, &specs); + bench_amortized_execution(c, &specs); +} + +fn bench_traversal(c: &mut Criterion, specs: &[LayoutSpec]) { + let mut group = c.benchmark_group("tick_circuit_layout/traversal"); + + for spec in specs { + group.throughput(Throughput::Elements(spec.gate_count as u64)); + group.bench_with_input( + BenchmarkId::new("tick_circuit_iter_gates", &spec.label), + spec, + |b, spec| { + b.iter(|| black_box(traverse_tick_circuit(black_box(&spec.tick_circuit)))); + }, + ); + group.bench_with_input( + BenchmarkId::new("tick_circuit_gate_batches", &spec.label), + spec, + |b, spec| { + b.iter(|| black_box(traverse_tick_circuit_batched(black_box(&spec.tick_circuit)))); + }, + ); + } + + group.finish(); +} + +fn bench_execution(c: &mut Criterion, specs: &[LayoutSpec]) { + let mut group = c.benchmark_group("tick_circuit_layout/execution_one_shot"); + + for spec in specs { + group.throughput(Throughput::Elements(spec.gate_count as u64)); + group.bench_with_input( + BenchmarkId::new("tick_circuit_direct", &spec.label), + spec, + |b, spec| { + b.iter(|| { + black_box(run_tick_circuit_direct( + black_box(&spec.tick_circuit), + spec.num_qubits, + )) + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("circuit_executor", &spec.label), + spec, + |b, spec| { + b.iter(|| { + black_box(run_tick_circuit_executor( + black_box(&spec.tick_circuit), + spec.num_qubits, + )) + }); + }, + ); + } + + group.finish(); +} + +fn bench_amortized_execution(c: &mut Criterion, specs: &[LayoutSpec]) { + let mut group = c.benchmark_group("tick_circuit_layout/execution_amortized_64_shots"); + + for spec in specs { + let throughput = spec.gate_count.saturating_mul(AMORTIZED_SHOTS); + group.throughput(Throughput::Elements(throughput as u64)); + group.bench_with_input( + BenchmarkId::new("tick_circuit_direct", &spec.label), + spec, + |b, spec| { + b.iter(|| { + let mut total = 0usize; + for _ in 0..AMORTIZED_SHOTS { + total = total.wrapping_add(run_tick_circuit_direct( + black_box(&spec.tick_circuit), + spec.num_qubits, + )); + } + black_box(total) + }); + }, + ); + group.bench_with_input( + BenchmarkId::new("circuit_executor", &spec.label), + spec, + |b, spec| { + b.iter(|| { + let mut total = 0usize; + for _ in 0..AMORTIZED_SHOTS { + total = total.wrapping_add(run_tick_circuit_executor( + black_box(&spec.tick_circuit), + spec.num_qubits, + )); + } + black_box(total) + }); + }, + ); + } + + group.finish(); +} + +fn build_surface_like_tick_circuit(distance: usize, rounds: usize) -> TickCircuit { + let num_data = distance * distance; + let plaquettes = (distance - 1) * (distance - 1); + let x_ancilla_start = num_data; + let z_ancilla_start = x_ancilla_start + plaquettes; + let total_qubits = surface_like_num_qubits(distance); + + let mut circuit = TickCircuit::new(); + let data_qubits = (0..num_data).collect::>(); + let ancilla_qubits = (num_data..total_qubits).collect::>(); + let x_ancillas = (x_ancilla_start..z_ancilla_start).collect::>(); + let all_qubits = (0..total_qubits).collect::>(); + + circuit.tick().pz(&all_qubits); + circuit.tick().h(&data_qubits); + + for _ in 0..rounds { + circuit.tick().pz(&ancilla_qubits); + circuit.tick().h(&x_ancillas); + + for neighbor in 0..4 { + let pairs = surface_like_pairs_for_neighbor(distance, neighbor); + add_disjoint_cx_layers(&mut circuit, total_qubits, pairs); + } + + circuit.tick().h(&x_ancillas); + circuit.tick().mz(&ancilla_qubits); + } + + circuit.tick().mz(&data_qubits); + circuit +} + +fn surface_like_num_qubits(distance: usize) -> usize { + let num_data = distance * distance; + let plaquettes = (distance - 1) * (distance - 1); + num_data + 2 * plaquettes +} + +fn surface_like_pairs_for_neighbor(distance: usize, neighbor: usize) -> Vec<(usize, usize)> { + let num_data = distance * distance; + let plaquettes_per_type = (distance - 1) * (distance - 1); + let x_ancilla_start = num_data; + let z_ancilla_start = x_ancilla_start + plaquettes_per_type; + let mut pairs = Vec::with_capacity(2 * plaquettes_per_type); + + for row in 0..(distance - 1) { + for col in 0..(distance - 1) { + let plaquette = row * (distance - 1) + col; + let x_ancilla = x_ancilla_start + plaquette; + let z_ancilla = z_ancilla_start + plaquette; + let data = match neighbor { + 0 => row * distance + col, + 1 => (row + 1) * distance + col, + 2 => row * distance + col + 1, + 3 => (row + 1) * distance + col + 1, + _ => unreachable!("neighbor index is in 0..4"), + }; + + pairs.push((x_ancilla, data)); + pairs.push((data, z_ancilla)); + } + } + + pairs +} + +fn add_disjoint_cx_layers( + circuit: &mut TickCircuit, + num_qubits: usize, + mut remaining: Vec<(usize, usize)>, +) { + while !remaining.is_empty() { + let mut used = vec![false; num_qubits]; + let mut layer = Vec::new(); + let mut next = Vec::new(); + + for (control, target) in remaining { + if !used[control] && !used[target] { + used[control] = true; + used[target] = true; + layer.push((control, target)); + } else { + next.push((control, target)); + } + } + + circuit.tick().cx(&layer); + remaining = next; + } +} + +fn traverse_tick_circuit(circuit: &TickCircuit) -> usize { + let mut total = 0usize; + for (tick_idx, tick) in circuit.iter_ticks() { + total = total.wrapping_add(tick_idx); + for gate in tick.gate_batches() { + total = total.wrapping_add(gate.num_gates()); + total = total.wrapping_add(gate.qubits.len()); + } + } + total +} + +fn traverse_tick_circuit_batched(circuit: &TickCircuit) -> usize { + let mut total = 0usize; + for (tick_idx, batch) in circuit.iter_gate_batches_with_tick() { + total = total.wrapping_add(tick_idx); + total = total.wrapping_add(batch.num_gates()); + total = total.wrapping_add(batch.qubits.len()); + } + total +} + +fn run_tick_circuit_direct(circuit: &TickCircuit, num_qubits: usize) -> usize { + let mut sim = SparseStab::new(num_qubits); + let mut measurement_count = 0usize; + + for (_tick_idx, tick) in circuit.iter_ticks() { + for gate in tick.gate_batches() { + measurement_count += execute_gate_direct(&mut sim, gate); + } + } + + measurement_count +} + +fn execute_gate_direct(sim: &mut S, gate: &Gate) -> usize { + match gate.gate_type { + GateType::PZ | GateType::QAlloc => { + sim.pz(&gate.qubits); + 0 + } + GateType::H => { + sim.h(&gate.qubits); + 0 + } + GateType::CX => { + let pairs = gate + .qubits + .chunks_exact(2) + .map(|pair| (pair[0], pair[1])) + .collect::>(); + sim.cx(&pairs); + 0 + } + GateType::MZ | GateType::MeasureFree => sim.mz(&gate.qubits).len(), + other => panic!("unsupported benchmark gate type: {other:?}"), + } +} + +fn run_tick_circuit_executor(circuit: &TickCircuit, num_qubits: usize) -> usize { + let mut sim = SparseStab::new(num_qubits); + CircuitExecutor::new(circuit).run(&mut sim).len() +} diff --git a/crates/pecos-chromobius/Cargo.toml b/crates/pecos-chromobius/Cargo.toml index f945d9a24..6b1528949 100644 --- a/crates/pecos-chromobius/Cargo.toml +++ b/crates/pecos-chromobius/Cargo.toml @@ -13,6 +13,7 @@ description = "Chromobius color code decoder for PECOS" [dependencies] pecos-decoder-core.workspace = true +pecos-pymatching.workspace = true ndarray.workspace = true thiserror.workspace = true cxx.workspace = true diff --git a/crates/pecos-chromobius/build_chromobius.rs b/crates/pecos-chromobius/build_chromobius.rs index 75a27a1dc..dd2d0bf94 100644 --- a/crates/pecos-chromobius/build_chromobius.rs +++ b/crates/pecos-chromobius/build_chromobius.rs @@ -59,7 +59,33 @@ pub fn build() -> Result<()> { let manifest = Manifest::find_and_load_validated()?; let chromobius_dir = ensure_dep_ready("chromobius", &manifest)?; let stim_dir = ensure_dep_ready("stim", &manifest)?; - let pymatching_dir = ensure_dep_ready("pymatching", &manifest)?; + // PyMatching headers and compiled objects come from pecos-pymatching via cargo metadata. + // This avoids compiling a second copy of PyMatching sources which would cause + // duplicate symbol errors at link time. + let pymatching_include = env::var("DEP_PYMATCHING_PECOS_PYMATCHING_INCLUDE").map_or_else( + |_| { + // Fallback: download and use directly (for standalone builds) + ensure_dep_ready("pymatching", &manifest) + .expect("pymatching dependency") + .join("src") + }, + PathBuf::from, + ); + let stim_include = env::var("DEP_PYMATCHING_PECOS_STIM_INCLUDE").map_or_else( + |_| { + ensure_dep_ready("stim", &manifest) + .expect("stim dependency") + .join("src") + }, + PathBuf::from, + ); + let stim_dir_for_header = env::var("DEP_PYMATCHING_PECOS_STIM_DIR").map_or_else( + |_| ensure_dep_ready("stim", &manifest).expect("stim dependency"), + PathBuf::from, + ); + let pymatching_lib_dir = env::var("DEP_PYMATCHING_PECOS_LIB_DIR") + .ok() + .map(PathBuf::from); // Apply compatibility patches for newer Stim version chromobius_patch::patch_chromobius_for_newer_stim(&chromobius_dir)?; @@ -67,21 +93,34 @@ pub fn build() -> Result<()> { // Generate amalgamated stim.h if needed build_stim::generate_amalgamated_header(&stim_dir)?; - // Build using cxx - build_cxx_bridge(&chromobius_dir, &stim_dir, &pymatching_dir)?; + // Build using cxx -- only chromobius and stim sources, NOT pymatching + build_cxx_bridge( + &chromobius_dir, + &stim_dir, + &pymatching_include, + &stim_include, + &stim_dir_for_header, + pymatching_lib_dir.as_deref(), + )?; Ok(()) } -fn build_cxx_bridge(chromobius_dir: &Path, stim_dir: &Path, pymatching_dir: &Path) -> Result<()> { +fn build_cxx_bridge( + chromobius_dir: &Path, + stim_dir: &Path, + pymatching_include: &Path, + stim_include: &Path, + stim_dir_for_header: &Path, + pymatching_lib_dir: Option<&Path>, +) -> Result<()> { let chromobius_src_dir = chromobius_dir.join("src"); let stim_src_dir = stim_dir.join("src"); - let pymatching_src_dir = pymatching_dir.join("src"); - // Find essential source files + // Find essential source files -- only chromobius and stim, NOT pymatching. + // PyMatching objects come from pecos-pymatching (linked, not compiled here). let chromobius_files = collect_chromobius_sources(&chromobius_src_dir)?; let stim_files = build_stim::collect_stim_sources(&stim_src_dir); - let pymatching_files = collect_pymatching_sources(&pymatching_src_dir)?; // Build the cxx bridge first to generate headers let mut build = cxx_build::bridge("src/bridge.rs"); @@ -101,18 +140,17 @@ fn build_cxx_bridge(chromobius_dir: &Path, stim_dir: &Path, pymatching_dir: &Pat build.file(file); } - // Add PyMatching files - for file in pymatching_files { - build.file(file); - } + // PyMatching objects are provided by pecos-pymatching -- NOT compiled here. + // We only need headers for compilation, not source files. // Configure build build .std("c++20") .include(&chromobius_src_dir) .include(&stim_src_dir) - .include(stim_dir) // For amalgamated stim.h - .include(&pymatching_src_dir) + .include(stim_dir_for_header) // For amalgamated stim.h + .include(pymatching_include) // PyMatching headers (from pecos-pymatching) + .include(stim_include) // Stim headers .include("include") .include("src") .define("CHROMOBIUS_BRIDGE_EXPORTS", None); @@ -173,6 +211,12 @@ fn build_cxx_bridge(chromobius_dir: &Path, stim_dir: &Path, pymatching_dir: &Pat build.compile("chromobius-bridge"); + // Link against pecos-pymatching's compiled PyMatching objects + if let Some(lib_dir) = pymatching_lib_dir { + println!("cargo:rustc-link-search=native={}", lib_dir.display()); + println!("cargo:rustc-link-lib=static=pymatching-bridge"); + } + // On macOS, link against the system C++ library if target.contains("darwin") { println!("cargo:rustc-link-search=native=/usr/lib"); @@ -193,19 +237,6 @@ fn collect_chromobius_sources(chromobius_src_dir: &Path) -> Result> Ok(files) } -fn collect_pymatching_sources(pymatching_src_dir: &Path) -> Result> { - let mut files = Vec::new(); - - // PyMatching sparse_blossom implementation files - let sparse_blossom_dir = pymatching_src_dir.join("pymatching/sparse_blossom"); - if sparse_blossom_dir.exists() { - collect_cc_files_filtered(&sparse_blossom_dir, &mut files)?; - } - - info!("Found {} PyMatching source files", files.len()); - Ok(files) -} - fn collect_cc_files_filtered(dir: &Path, files: &mut Vec) -> Result<()> { for entry in fs::read_dir(dir)? { let entry = entry?; diff --git a/crates/pecos-chromobius/pecos.toml b/crates/pecos-chromobius/pecos.toml index 4c4f5c34d..0d19b9545 100644 --- a/crates/pecos-chromobius/pecos.toml +++ b/crates/pecos-chromobius/pecos.toml @@ -4,19 +4,16 @@ version = 1 [dependencies.chromobius] -version = "35e289570fdc1d71e73582e1fd4e0c8e29298ef5" -url = "https://github.com/quantumlib/chromobius/archive/35e289570fdc1d71e73582e1fd4e0c8e29298ef5.tar.gz" -sha256 = "da73d819e67572065fd715db45fabb342c2a2a1e961d2609df4f9864b9836054" +version = "acd09febcd6d4c9001b4d708507c8bfb10e4322e" +url = "https://github.com/quantumlib/chromobius/archive/acd09febcd6d4c9001b4d708507c8bfb10e4322e.tar.gz" +sha256 = "4aff65fbf3e5a4ed2974f9a48b772274ffa6129daa4b82bfc4182808cbe6360f" description = "Color code decoder" -[dependencies.pymatching] -version = "2b72b2c558eec678656da20ab6c358aa123fb664" -url = "https://github.com/oscarhiggott/PyMatching/archive/2b72b2c558eec678656da20ab6c358aa123fb664.tar.gz" -sha256 = "1470520b66ad7899f85020664aeeadfc6e2967f0b5e19ad205829968b845cd70" -description = "MWPM decoder" +# PyMatching is NOT downloaded here -- pecos-pymatching provides the compiled +# objects and headers via cargo metadata (links = "pymatching-pecos"). [dependencies.stim] -version = "bd60b73525fd5a9b30839020eb7554ad369e4337" -url = "https://github.com/quantumlib/Stim/archive/bd60b73525fd5a9b30839020eb7554ad369e4337.tar.gz" -sha256 = "2a4be24295ce3018d79e08369b31e401a2d33cd8b3a75675d57dac3afd9de37d" +version = "213275795e49027772bea7c610b6aac3a80583e1" +url = "https://github.com/quantumlib/Stim/archive/213275795e49027772bea7c610b6aac3a80583e1.tar.gz" +sha256 = "b63c4ae94494a8819d440757776cf80a829ec1e30454fa7c9b0f670e981938ab" description = "Stabilizer simulator for QEC" diff --git a/crates/pecos-cli/src/cli.rs b/crates/pecos-cli/src/cli.rs index f7e14a19f..7d2d5d2bf 100644 --- a/crates/pecos-cli/src/cli.rs +++ b/crates/pecos-cli/src/cli.rs @@ -11,6 +11,7 @@ pub mod cuda_cmd; pub mod cuquantum_cmd; +pub mod env_cmd; pub mod gpu_cmd; pub mod info; pub mod install_cmd; diff --git a/crates/pecos-cli/src/cli/env_cmd.rs b/crates/pecos-cli/src/cli/env_cmd.rs new file mode 100644 index 000000000..4f8007142 --- /dev/null +++ b/crates/pecos-cli/src/cli/env_cmd.rs @@ -0,0 +1,130 @@ +// 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. + +//! Implementation of the `env` subcommand. +//! +//! Prints the build environment variables for the current platform. This is +//! the single source of truth for platform-specific build configuration. +//! CI workflows, Justfile recipes, and `pecos python build` should all derive +//! their environment from this command. +//! +//! Usage: +//! eval $(pecos env) # bash/zsh — set variables in current shell +//! pecos env --format json # machine-readable output +//! pecos env --show # human-readable display + +use std::collections::BTreeMap; +use std::fmt::Write; + +/// Collect the build environment for the current platform. +/// +/// Returns a map of environment variable names to values. Only includes +/// variables that PECOS needs to set — does not duplicate the entire shell +/// environment. +pub fn collect_env() -> BTreeMap { + let mut env = BTreeMap::new(); + + // LLVM + if let Some(llvm_path) = pecos_build::llvm::find_llvm_14(None) { + let llvm_str = llvm_path.display().to_string(); + env.insert("LLVM_SYS_140_PREFIX".into(), llvm_str); + + // Add LLVM bin to PATH + let bin_path = llvm_path.join("bin"); + if bin_path.exists() { + let current_path = std::env::var("PATH").unwrap_or_default(); + env.insert( + "PATH".into(), + format!("{}:{current_path}", bin_path.display()), + ); + } + } + + // macOS-specific + #[cfg(target_os = "macos")] + { + // SDKROOT — needed for bindgen/clang to find system headers + if std::env::var("SDKROOT").is_err() + && let Ok(output) = std::process::Command::new("xcrun") + .args(["--show-sdk-path"]) + .output() + && output.status.success() + { + let sdk = String::from_utf8_lossy(&output.stdout).trim().to_string(); + if !sdk.is_empty() { + env.insert("SDKROOT".into(), sdk); + } + } + + // Deployment target + env.insert("MACOSX_DEPLOYMENT_TARGET".into(), "13.2".into()); + } + + // CUDA + if let Some(cuda_path) = pecos_build::cuda::find_cuda() { + env.insert("CUDA_PATH".into(), cuda_path.display().to_string()); + } + + // cuQuantum + if let Some(cuquantum_path) = pecos_build::cuquantum::find_cuquantum() { + env.insert( + "CUQUANTUM_ROOT".into(), + cuquantum_path.display().to_string(), + ); + } + + env +} + +/// Print environment in shell-eval format: `export KEY="VALUE"` +pub fn print_shell(env: &BTreeMap) { + for (key, value) in env { + println!("export {key}=\"{value}\""); + } +} + +/// Print environment in JSON format. +pub fn print_json(env: &BTreeMap) { + let mut out = String::from("{\n"); + for (i, (key, value)) in env.iter().enumerate() { + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + let _ = write!(out, " \"{key}\": \"{escaped}\""); + if i + 1 < env.len() { + out.push(','); + } + out.push('\n'); + } + out.push('}'); + println!("{out}"); +} + +/// Print environment in human-readable format. +pub fn print_show(env: &BTreeMap) { + if env.is_empty() { + println!("No PECOS-specific environment variables needed."); + return; + } + println!("PECOS build environment:"); + for (key, value) in env { + println!(" {key}={value}"); + } +} + +/// Run the env subcommand. +pub fn run(format: &str) { + let env = collect_env(); + match format { + "json" => print_json(&env), + "show" => print_show(&env), + _ => print_shell(&env), + } +} diff --git a/crates/pecos-cli/src/cli/python_cmd.rs b/crates/pecos-cli/src/cli/python_cmd.rs index 565f6aa73..c37cac0ad 100644 --- a/crates/pecos-cli/src/cli/python_cmd.rs +++ b/crates/pecos-cli/src/cli/python_cmd.rs @@ -3,7 +3,7 @@ use pecos_build::Result; use pecos_build::errors::Error; use std::fs; -use std::path::PathBuf; +use std::path::{Path, PathBuf}; use std::process::Command; /// Run the python subcommand @@ -118,6 +118,8 @@ fn run_build(profile: &str, rustflags: Option<&str>, cuda: bool) -> Result<()> { } ); + remove_stale_extension_artifacts(&repo_root, profile, crate_name)?; + let maturin = venv_bin.join("maturin"); let mut cmd = Command::new(&maturin); cmd.args(["develop", "--uv"]); @@ -149,6 +151,16 @@ fn run_build(profile: &str, rustflags: Option<&str>, cuda: bool) -> Result<()> { cmd.env("LIBRARY_PATH", "/usr/lib"); } + // Apply PECOS build environment (SDKROOT, LLVM, CUDA, etc.) + // This is the single source of truth — same logic as `pecos env`. + let build_env = super::env_cmd::collect_env(); + for (key, value) in &build_env { + // Don't override PATH — we already set it above with venv + if key != "PATH" { + cmd.env(key, value); + } + } + let status = cmd.status(); match status { Ok(s) if s.success() => {} @@ -192,3 +204,112 @@ fn run_build(profile: &str, rustflags: Option<&str>, cuda: bool) -> Result<()> { ))), } } + +fn cargo_profile_dir(profile: &str) -> &'static str { + if matches!(profile, "release" | "native") { + "release" + } else { + "debug" + } +} + +fn extension_library_filename(crate_name: &str) -> String { + let module_name = crate_name.replace('-', "_"); + + #[cfg(target_os = "windows")] + { + format!("{module_name}.dll") + } + + #[cfg(target_os = "macos")] + { + format!("lib{module_name}.dylib") + } + + #[cfg(all(unix, not(target_os = "macos")))] + { + format!("lib{module_name}.so") + } +} + +fn extension_artifact_candidates( + repo_root: &Path, + profile: &str, + crate_name: &str, +) -> [PathBuf; 3] { + let filename = extension_library_filename(crate_name); + let target_dir = repo_root.join("target"); + let profile_dir = target_dir.join(cargo_profile_dir(profile)); + + [ + profile_dir.join(&filename), + profile_dir.join("deps").join(&filename), + target_dir.join("maturin").join(&filename), + ] +} + +fn remove_stale_extension_artifacts( + repo_root: &Path, + profile: &str, + crate_name: &str, +) -> Result<()> { + let [profile_artifact, deps_artifact, maturin_staging_artifact] = + extension_artifact_candidates(repo_root, profile, crate_name); + + for path in [profile_artifact, deps_artifact] { + match fs::metadata(&path) { + Ok(metadata) if metadata.is_file() && metadata.len() == 0 => { + println!( + "Removing zero-byte extension artifact before rebuild: {}", + path.display() + ); + fs::remove_file(path)?; + } + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + } + + match fs::metadata(&maturin_staging_artifact) { + Ok(metadata) if metadata.is_file() => { + println!( + "Removing stale maturin staging artifact before rebuild: {}", + maturin_staging_artifact.display() + ); + fs::remove_file(maturin_staging_artifact)?; + } + Ok(_) => {} + Err(err) if err.kind() == std::io::ErrorKind::NotFound => {} + Err(err) => return Err(err.into()), + } + + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cargo_profile_dir_maps_native_to_release() { + assert_eq!(cargo_profile_dir("debug"), "debug"); + assert_eq!(cargo_profile_dir("release"), "release"); + assert_eq!(cargo_profile_dir("native"), "release"); + } + + #[test] + fn extension_artifact_candidates_use_python_module_name() { + let repo = PathBuf::from("/repo"); + let candidates = extension_artifact_candidates(&repo, "debug", "pecos-rslib-llvm"); + let filename = extension_library_filename("pecos-rslib-llvm"); + + assert!(filename.contains("pecos_rslib_llvm")); + assert_eq!(candidates[0], repo.join("target/debug").join(&filename)); + assert_eq!( + candidates[1], + repo.join("target/debug/deps").join(&filename) + ); + assert_eq!(candidates[2], repo.join("target/maturin").join(&filename)); + } +} diff --git a/crates/pecos-cli/src/cli/rust_cmd.rs b/crates/pecos-cli/src/cli/rust_cmd.rs index 8cfbbaec3..a891d97f7 100644 --- a/crates/pecos-cli/src/cli/rust_cmd.rs +++ b/crates/pecos-cli/src/cli/rust_cmd.rs @@ -373,6 +373,8 @@ fn run_test(release: bool, include_ffi: bool) -> Result<()> { println!("Testing workspace packages..."); // runtime = sim + qasm + phir (format parsers) // hugr = qis (includes llvm) + hugr compilation + // 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"]; for crate_name in FFI_CRATES { @@ -384,6 +386,8 @@ fn run_test(release: bool, include_ffi: bool) -> Result<()> { "--exclude", "pecos-cuquantum", // Requires cuQuantum SDK, test separately if available "--exclude", + "pecos-cli", // Test separately with --features=runtime (see below) + "--exclude", "pecos-decoders", "--exclude", "pecos-gpu-sims", // Always exclude from workspace test, test separately if GPU available @@ -397,6 +401,22 @@ fn run_test(release: bool, include_ffi: bool) -> Result<()> { return Err(Error::Config("cargo test (workspace) failed".to_string())); } + // Test pecos-cli separately with --features=runtime. + // cargo test --workspace --features=runtime overwrites the pecos binary + // WITHOUT runtime features (cargo feature unification bug), so the CLI + // integration tests that invoke `cargo_bin!("pecos")` would get a broken + // binary. Testing separately ensures the binary is built correctly. + println!("Testing pecos-cli with runtime features..."); + let mut cli_args: Vec<&str> = vec!["test", "-p", "pecos-cli", "--features=runtime"]; + if !release_flag.is_empty() { + cli_args.push(release_flag); + } + if !run_cargo_command(&cli_args) { + return Err(Error::Config( + "cargo test (pecos-cli with runtime) 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"); diff --git a/crates/pecos-cli/src/main.rs b/crates/pecos-cli/src/main.rs index 3e58d5619..27f203b7e 100644 --- a/crates/pecos-cli/src/main.rs +++ b/crates/pecos-cli/src/main.rs @@ -112,6 +112,21 @@ enum Commands { #[command(subcommand)] command: DepsCommands, }, + /// Print build environment variables for the current platform + /// + /// Use `eval $(pecos env)` in bash/zsh to set variables in the current + /// shell. All platform-specific build configuration (LLVM paths, SDKROOT, + /// CUDA, cuQuantum) is detected and printed. + /// + /// Example: eval $(pecos env) + /// Example: pecos env --format show + /// Example: pecos env --format json + Env { + /// Output format: shell (default), json, show + #[arg(long, default_value = "shell")] + format: String, + }, + /// Set up build environment (detect and install missing dependencies) /// /// Interactively checks for LLVM, CUDA, and cuQuantum and offers to @@ -677,6 +692,7 @@ fn main() -> Result<(), Box> { Commands::Llvm { command } => cli::llvm_cmd::run(command.clone())?, Commands::Selene { command } => cli::selene_cmd::run(command.clone())?, Commands::Deps { command } => cli::manifest_cmd::run(command.clone())?, + Commands::Env { format } => cli::env_cmd::run(format), Commands::Setup { yes, no, diff --git a/crates/pecos-core/src/channel.rs b/crates/pecos-core/src/channel.rs new file mode 100644 index 000000000..8109b0766 --- /dev/null +++ b/crates/pecos-core/src/channel.rs @@ -0,0 +1,322 @@ +// 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. + +//! Symbolic quantum-channel namespace. +//! +//! Constructors in this module return [`ChannelExpr`]. Use this namespace for +//! physical noise, open-system maps, and other CPTP processes: +//! +//! ``` +//! use pecos_core::channel::*; +//! +//! let noise = Depolarizing(0.001, 0) & BitFlip(0.01, 1); +//! ``` +//! +//! Ideal gates can be lifted into this level with [`from_gate`]. Unitary +//! operations and ideal gates are not noise, but they are valid channels when a +//! channel-level expression is needed. + +use crate::op::Op; +use crate::qubit_support::overlapping_qubits; +use crate::{GateExpr, PauliString, QubitId, UnitaryRep, op}; +use std::ops::{BitAnd, Mul}; + +pub use crate::op::ChannelExpr; + +fn channel_from_op(op: Op) -> ChannelExpr { + op.into_channel() +} + +/// Lifts a unitary expression to the channel level. +#[must_use] +pub fn from_unitary(unitary: impl Into) -> ChannelExpr { + ChannelExpr::Unitary(unitary.into()) +} + +/// Lifts an ideal gate expression to the channel level. +#[must_use] +pub fn from_gate(gate: impl Into) -> ChannelExpr { + ChannelExpr::Gate(gate.into()) +} + +impl From for ChannelExpr { + fn from(unitary: UnitaryRep) -> Self { + ChannelExpr::Unitary(unitary) + } +} + +impl From for ChannelExpr { + fn from(pauli: PauliString) -> Self { + ChannelExpr::Unitary(UnitaryRep::from(pauli)) + } +} + +impl From for ChannelExpr { + fn from(gate: GateExpr) -> Self { + ChannelExpr::Gate(gate) + } +} + +/// Single-qubit depolarizing channel. +#[allow(non_snake_case)] +#[must_use] +pub fn Depolarizing(p: f64, qubit: impl Into) -> ChannelExpr { + channel_from_op(op::Depolarizing(p, qubit)) +} + +/// Dephasing channel. +#[allow(non_snake_case)] +#[must_use] +pub fn Dephasing(p: f64, qubit: impl Into) -> ChannelExpr { + channel_from_op(op::Dephasing(p, qubit)) +} + +/// Bit-flip channel. +#[allow(non_snake_case)] +#[must_use] +pub fn BitFlip(p: f64, qubit: impl Into) -> ChannelExpr { + channel_from_op(op::BitFlip(p, qubit)) +} + +/// Bit-phase-flip channel. +#[allow(non_snake_case)] +#[must_use] +pub fn BitPhaseFlip(p: f64, qubit: impl Into) -> ChannelExpr { + channel_from_op(op::BitPhaseFlip(p, qubit)) +} + +/// General single-qubit Pauli channel. +#[allow(non_snake_case)] +#[must_use] +pub fn PauliChannel(px: f64, py: f64, pz: f64, qubit: impl Into) -> ChannelExpr { + channel_from_op(op::PauliChannel(px, py, pz, qubit)) +} + +/// Two-qubit depolarizing channel. +#[allow(non_snake_case)] +#[must_use] +pub fn Depolarizing2(p: f64, q0: impl Into, q1: impl Into) -> ChannelExpr { + channel_from_op(op::Depolarizing2(p, q0, q1)) +} + +/// Amplitude damping channel. +#[allow(non_snake_case)] +#[must_use] +pub fn AmplitudeDamping(gamma: f64, qubit: impl Into) -> ChannelExpr { + channel_from_op(op::AmplitudeDamping(gamma, qubit)) +} + +/// Phase damping channel. +#[allow(non_snake_case)] +#[must_use] +pub fn PhaseDamping(lambda: f64, qubit: impl Into) -> ChannelExpr { + channel_from_op(op::PhaseDamping(lambda, qubit)) +} + +/// Erasure channel. +#[allow(non_snake_case)] +#[must_use] +pub fn Erasure(prob: f64, qubit: impl Into) -> ChannelExpr { + channel_from_op(op::Erasure(prob, qubit)) +} + +/// Leakage channel. +#[allow(non_snake_case)] +#[must_use] +pub fn Leakage(rate: f64, qubit: impl Into) -> ChannelExpr { + channel_from_op(op::Leakage(rate, qubit)) +} + +impl BitAnd for ChannelExpr { + type Output = ChannelExpr; + + fn bitand(self, rhs: ChannelExpr) -> ChannelExpr { + let overlap = overlapping_qubits(self.qubits(), rhs.qubits()); + assert!( + overlap.is_empty(), + "tensor product requires disjoint channel support; overlapping qubits: {overlap:?}" + ); + ChannelExpr::Tensor(vec![self, rhs]) + } +} + +impl BitAnd<&ChannelExpr> for ChannelExpr { + type Output = ChannelExpr; + + fn bitand(self, rhs: &ChannelExpr) -> ChannelExpr { + self & rhs.clone() + } +} + +impl BitAnd for &ChannelExpr { + type Output = ChannelExpr; + + fn bitand(self, rhs: ChannelExpr) -> ChannelExpr { + self.clone() & rhs + } +} + +impl BitAnd<&ChannelExpr> for &ChannelExpr { + type Output = ChannelExpr; + + fn bitand(self, rhs: &ChannelExpr) -> ChannelExpr { + self.clone() & rhs.clone() + } +} + +impl BitAnd for ChannelExpr { + type Output = ChannelExpr; + + fn bitand(self, rhs: GateExpr) -> ChannelExpr { + self & ChannelExpr::Gate(rhs) + } +} + +impl BitAnd for GateExpr { + type Output = ChannelExpr; + + fn bitand(self, rhs: ChannelExpr) -> ChannelExpr { + ChannelExpr::Gate(self) & rhs + } +} + +impl Mul for ChannelExpr { + type Output = ChannelExpr; + + fn mul(self, rhs: ChannelExpr) -> ChannelExpr { + ChannelExpr::Compose(vec![self, rhs]) + } +} + +impl Mul<&ChannelExpr> for ChannelExpr { + type Output = ChannelExpr; + + fn mul(self, rhs: &ChannelExpr) -> ChannelExpr { + self * rhs.clone() + } +} + +impl Mul for &ChannelExpr { + type Output = ChannelExpr; + + fn mul(self, rhs: ChannelExpr) -> ChannelExpr { + self.clone() * rhs + } +} + +impl Mul<&ChannelExpr> for &ChannelExpr { + type Output = ChannelExpr; + + fn mul(self, rhs: &ChannelExpr) -> ChannelExpr { + self.clone() * rhs.clone() + } +} + +impl Mul for ChannelExpr { + type Output = ChannelExpr; + + fn mul(self, rhs: GateExpr) -> ChannelExpr { + self * ChannelExpr::Gate(rhs) + } +} + +impl Mul for GateExpr { + type Output = ChannelExpr; + + fn mul(self, rhs: ChannelExpr) -> ChannelExpr { + ChannelExpr::Gate(self) * rhs + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::gate; + + #[test] + fn noise_constructors_return_channel_expr() { + assert!(matches!(Depolarizing(0.1, 0), ChannelExpr::MixedUnitary(_))); + assert!(matches!(Dephasing(0.1, 0), ChannelExpr::MixedUnitary(_))); + assert!(matches!(BitFlip(0.1, 0), ChannelExpr::MixedUnitary(_))); + assert!(matches!(BitPhaseFlip(0.1, 0), ChannelExpr::MixedUnitary(_))); + assert!(matches!( + PauliChannel(0.1, 0.2, 0.3, 0), + ChannelExpr::MixedUnitary(_) + )); + assert!(matches!( + Depolarizing2(0.1, 0, 1), + ChannelExpr::MixedUnitary(_) + )); + assert!(matches!( + AmplitudeDamping(0.1, 0), + ChannelExpr::AmplitudeDamping { .. } + )); + assert!(matches!( + PhaseDamping(0.1, 0), + ChannelExpr::PhaseDamping { .. } + )); + assert!(matches!(Erasure(0.1, 0), ChannelExpr::Erasure { .. })); + assert!(matches!(Leakage(0.1, 0), ChannelExpr::Leakage { .. })); + } + + #[test] + #[should_panic(expected = "Depolarizing2 requires distinct qubits")] + fn channel_namespace_two_qubit_channel_rejects_repeated_qubit() { + let _ = Depolarizing2(0.1, 0, 0); + } + + #[test] + fn ideal_gate_lifts_to_channel_expr() { + let channel = from_gate(gate::MZ(0)); + assert!(matches!( + channel, + ChannelExpr::Gate(GateExpr::Measure { .. }) + )); + } + + #[test] + fn channel_tensor_and_composition_stay_channel_level() { + let tensor = Depolarizing(0.1, 0) & BitFlip(0.2, 1); + assert!(matches!(tensor, ChannelExpr::Tensor(parts) if parts.len() == 2)); + + let sequence = AmplitudeDamping(0.1, 0) * PhaseDamping(0.2, 0); + assert!(matches!(sequence, ChannelExpr::Compose(parts) if parts.len() == 2)); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint channel support")] + fn channel_tensor_rejects_overlapping_qubits() { + let _ = Depolarizing(0.1, 0) & BitFlip(0.2, 0); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint channel support")] + fn channel_tensor_rejects_partial_overlap_with_multi_qubit_support() { + let _ = Depolarizing2(0.1, 0, 2) & BitFlip(0.2, 2); + } + + #[test] + fn channel_tensor_uses_sparse_support_not_dense_span() { + let tensor = Depolarizing2(0.1, 0, 2) & BitFlip(0.2, 1); + assert!(matches!(tensor, ChannelExpr::Tensor(ref parts) if parts.len() == 2)); + assert_eq!(tensor.qubits(), vec![0, 1, 2]); + } + + #[test] + fn gate_channel_combinations_promote_to_channel_level() { + let tensor = gate::H(0) & Depolarizing(0.1, 1); + assert!(matches!(tensor, ChannelExpr::Tensor(parts) if parts.len() == 2)); + + let sequence = Depolarizing(0.1, 0) * gate::MZ(0); + assert!(matches!(sequence, ChannelExpr::Compose(parts) if parts.len() == 2)); + } +} diff --git a/crates/pecos-core/src/clifford.rs b/crates/pecos-core/src/clifford.rs index cd767989e..a92cd72f3 100644 --- a/crates/pecos-core/src/clifford.rs +++ b/crates/pecos-core/src/clifford.rs @@ -10,36 +10,37 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. -//! Named Clifford gate primitives. +//! Clifford gate namespace. //! -//! The base Clifford gates (single-qubit and two-qubit), analogous to [`Pauli`] -//! for the Pauli group. The 24 single-qubit elements form a closed group with -//! fast composition via lookup. Two-qubit gates are the standard entangling primitives. -//! -//! # Example +//! This module provides the [`Clifford`] enum (named gate primitives) and +//! re-exports [`CliffordRep`] with free constructors for the Heisenberg-picture +//! representation. Use `use pecos_core::clifford::*` for Clifford-level work: //! //! ``` -//! use pecos_core::clifford::Clifford; -//! use pecos_core::Pauli; -//! use pecos_core::Sign; +//! use pecos_core::clifford::*; //! -//! let h = Clifford::H; -//! let (sign, p) = h.conjugate(Pauli::X); -//! assert_eq!(p, Pauli::Z); -//! assert_eq!(sign, Sign::PlusOne); +//! // Constructors return CliffordRep (composable Heisenberg-picture gates) +//! let layer = CX(0, 1) * H(0); //! -//! // Two-qubit gates -//! let cx_rep = Clifford::CX.on_qubits(0, 1); -//! assert!(cx_rep.is_valid()); +//! // The Clifford enum provides named gate primitives with Pauli conjugation +//! let h = Clifford::H; +//! let (sign, p) = h.conjugate(pecos_core::Pauli::X); +//! assert_eq!(p, pecos_core::Pauli::Z); //! ``` -use crate::clifford_rep::CliffordRep; use crate::gate_type::GateType; use crate::unitary_rep::UnitaryRep; use crate::{Angle64, Pauli, QubitId, Sign}; use std::fmt; use std::ops::Mul; +// Re-export CliffordRep and its constructors so `use pecos_core::clifford::*` works. +pub use crate::clifford_rep::CliffordRep; +pub use crate::clifford_rep::constructors::{ + CX, CY, CZ, F, F2, F2dg, F3, F3dg, F4, F4dg, Fdg, G, Gdg, H, H2, H3, H4, H5, H6, ISWAP, + ISWAPdg, Id, SWAP, SX, SXX, SXXdg, SXdg, SY, SYY, SYYdg, SYdg, SZ, SZZ, SZZdg, SZdg, +}; + /// Named Clifford gate primitive. /// /// Includes all 24 single-qubit Clifford gates and the standard two-qubit gates. @@ -984,6 +985,12 @@ mod tests { } } + #[test] + #[should_panic(expected = "SWAP requires distinct qubits")] + fn test_2q_on_qubits_rejects_repeated_qubit() { + let _ = Clifford::SWAP.on_qubits(0, 0); + } + #[test] fn test_on_qubits_noncontiguous() { let rep = Clifford::CX.on_qubits(0, 3); diff --git a/crates/pecos-core/src/clifford_rep.rs b/crates/pecos-core/src/clifford_rep.rs index f65e428b7..efb7bb4f5 100644 --- a/crates/pecos-core/src/clifford_rep.rs +++ b/crates/pecos-core/src/clifford_rep.rs @@ -19,7 +19,7 @@ //! //! ``` //! use pecos_core::clifford_rep::CliffordRep; -//! use pecos_core::unitary_rep::{X, Z}; +//! use pecos_core::unitary::{X, Z}; //! //! // Hadamard swaps X <-> Z //! let h = CliffordRep::h(0); @@ -29,6 +29,7 @@ //! ``` use crate::pauli::algebra::i; +use crate::qubit_support::{assert_distinct_qubits, overlapping_qubits}; use crate::unitary_rep::UnitaryRep; use crate::{Pauli, PauliString, Phase, QuarterPhase}; use rand::RngExt; @@ -70,6 +71,27 @@ impl CliffordRep { self.num_qubits } + /// Returns qubits where this Clifford acts non-trivially. + /// + /// This is the operator support, not the tableau span. Identity action on + /// spectator qubits is omitted. + #[must_use] + pub fn support_qubits(&self) -> Vec { + let mut support = Vec::new(); + for q in 0..self.num_qubits { + let x_image = self.x_image(q); + let z_image = self.z_image(q); + if *x_image != PauliString::x(q) || *z_image != PauliString::z(q) { + support.push(q); + support.extend(x_image.qubits()); + support.extend(z_image.qubits()); + } + } + support.sort_unstable(); + support.dedup(); + support + } + /// Returns how X on the given qubit transforms. #[must_use] pub fn x_image(&self, qubit: usize) -> &PauliString { @@ -186,7 +208,7 @@ impl CliffordRep { /// /// ``` /// use pecos_core::clifford_rep::CliffordRep; - /// use pecos_core::unitary_rep::{X, Z}; + /// use pecos_core::unitary::{X, Z}; /// /// let h = CliffordRep::h(0); /// let stabilizer = X(0) & Z(1); @@ -551,6 +573,7 @@ impl CliffordRep { /// `X_t` -> `X_t`, `Z_t` -> `Z_c` `Z_t` #[must_use] pub fn cx(control: usize, target: usize) -> Self { + assert_distinct_qubits("CX", [control, target]); let num_qubits = control.max(target) + 1; let mut cliff = Self::identity(num_qubits); @@ -569,6 +592,7 @@ impl CliffordRep { /// `X_1` -> `Z_0` `X_1`, `Z_1` -> `Z_1` #[must_use] pub fn cz(q0: usize, q1: usize) -> Self { + assert_distinct_qubits("CZ", [q0, q1]); let num_qubits = q0.max(q1) + 1; let mut cliff = Self::identity(num_qubits); @@ -587,6 +611,7 @@ impl CliffordRep { /// `X_1` -> `X_0`, `Z_1` -> `Z_0` #[must_use] pub fn swap(q0: usize, q1: usize) -> Self { + assert_distinct_qubits("SWAP", [q0, q1]); let num_qubits = q0.max(q1) + 1; let mut cliff = Self::identity(num_qubits); @@ -606,6 +631,7 @@ impl CliffordRep { /// `X_t` -> `Z_c` `X_t`, `Z_t` -> `Z_c` `Z_t` #[must_use] pub fn cy(control: usize, target: usize) -> Self { + assert_distinct_qubits("CY", [control, target]); let num_qubits = control.max(target) + 1; let mut cliff = Self::identity(num_qubits); @@ -624,6 +650,7 @@ impl CliffordRep { /// SXX gate (sqrt XX): XI -> XI, IX -> IX, ZI -> -YX, IZ -> -XY #[must_use] pub fn sxx(q0: usize, q1: usize) -> Self { + assert_distinct_qubits("SXX", [q0, q1]); let num_qubits = q0.max(q1) + 1; let mut cliff = Self::identity(num_qubits); cliff.z_images[q0] = -(PauliString::y(q0) & PauliString::x(q1)); @@ -634,6 +661,7 @@ impl CliffordRep { /// SXX† gate: XI -> XI, IX -> IX, ZI -> YX, IZ -> XY #[must_use] pub fn sxxdg(q0: usize, q1: usize) -> Self { + assert_distinct_qubits("SXXdg", [q0, q1]); let num_qubits = q0.max(q1) + 1; let mut cliff = Self::identity(num_qubits); cliff.z_images[q0] = PauliString::y(q0) & PauliString::x(q1); @@ -644,6 +672,7 @@ impl CliffordRep { /// SYY gate (sqrt YY): XI -> -ZY, IX -> -YZ, ZI -> XY, IZ -> YX #[must_use] pub fn syy(q0: usize, q1: usize) -> Self { + assert_distinct_qubits("SYY", [q0, q1]); let num_qubits = q0.max(q1) + 1; let mut cliff = Self::identity(num_qubits); cliff.x_images[q0] = -(PauliString::z(q0) & PauliString::y(q1)); @@ -656,6 +685,7 @@ impl CliffordRep { /// SYY† gate: XI -> ZY, IX -> YZ, ZI -> -XY, IZ -> -YX #[must_use] pub fn syydg(q0: usize, q1: usize) -> Self { + assert_distinct_qubits("SYYdg", [q0, q1]); let num_qubits = q0.max(q1) + 1; let mut cliff = Self::identity(num_qubits); cliff.x_images[q0] = PauliString::z(q0) & PauliString::y(q1); @@ -668,6 +698,7 @@ impl CliffordRep { /// SZZ gate (sqrt ZZ): XI -> YZ, IX -> ZY, ZI -> ZI, IZ -> IZ #[must_use] pub fn szz(q0: usize, q1: usize) -> Self { + assert_distinct_qubits("SZZ", [q0, q1]); let num_qubits = q0.max(q1) + 1; let mut cliff = Self::identity(num_qubits); cliff.x_images[q0] = PauliString::y(q0) & PauliString::z(q1); @@ -678,6 +709,7 @@ impl CliffordRep { /// SZZ† gate: XI -> -YZ, IX -> -ZY, ZI -> ZI, IZ -> IZ #[must_use] pub fn szzdg(q0: usize, q1: usize) -> Self { + assert_distinct_qubits("SZZdg", [q0, q1]); let num_qubits = q0.max(q1) + 1; let mut cliff = Self::identity(num_qubits); cliff.x_images[q0] = -(PauliString::y(q0) & PauliString::z(q1)); @@ -688,6 +720,7 @@ impl CliffordRep { /// iSWAP gate: XI -> ZY, IX -> YZ, ZI -> IZ, IZ -> ZI #[must_use] pub fn iswap(q0: usize, q1: usize) -> Self { + assert_distinct_qubits("ISWAP", [q0, q1]); let num_qubits = q0.max(q1) + 1; let mut cliff = Self::identity(num_qubits); cliff.x_images[q0] = PauliString::z(q0) & PauliString::y(q1); @@ -700,6 +733,7 @@ impl CliffordRep { /// G gate: XI -> IX, IX -> XI, ZI -> XZ, IZ -> ZX #[must_use] pub fn g(q0: usize, q1: usize) -> Self { + assert_distinct_qubits("G", [q0, q1]); let num_qubits = q0.max(q1) + 1; let mut cliff = Self::identity(num_qubits); cliff.x_images[q0] = PauliString::x(q1); @@ -714,6 +748,7 @@ impl CliffordRep { /// Both X images have opposite signs from iSWAP (the Z images are the same). #[must_use] pub fn iswapdg(q0: usize, q1: usize) -> Self { + assert_distinct_qubits("ISWAPdg", [q0, q1]); let num_qubits = q0.max(q1) + 1; let mut cliff = Self::identity(num_qubits); cliff.x_images[q0] = -(PauliString::z(q0) & PauliString::y(q1)); @@ -889,11 +924,22 @@ impl Mul<&CliffordRep> for &CliffordRep { // --- BitAnd trait: & operator for tensor product --- +fn assert_disjoint_clifford_support(lhs: &CliffordRep, rhs: &CliffordRep) { + let lhs_support = lhs.support_qubits(); + let rhs_support = rhs.support_qubits(); + let overlap = overlapping_qubits(lhs_support, rhs_support); + assert!( + overlap.is_empty(), + "tensor product requires disjoint Clifford support; overlapping qubits: {overlap:?}" + ); +} + impl BitAnd for CliffordRep { type Output = CliffordRep; /// Tensor product of two `CliffordReps` acting on disjoint qubits. fn bitand(self, rhs: CliffordRep) -> CliffordRep { + assert_disjoint_clifford_support(&self, &rhs); // Since compose auto-extends with identity on extra qubits, // and these CliffordReps act on disjoint qubits, composing gives the tensor. self.compose(&rhs) @@ -904,6 +950,7 @@ impl BitAnd<&CliffordRep> for CliffordRep { type Output = CliffordRep; fn bitand(self, rhs: &CliffordRep) -> CliffordRep { + assert_disjoint_clifford_support(&self, rhs); self.compose(rhs) } } @@ -912,6 +959,7 @@ impl BitAnd for &CliffordRep { type Output = CliffordRep; fn bitand(self, rhs: CliffordRep) -> CliffordRep { + assert_disjoint_clifford_support(self, &rhs); self.compose(&rhs) } } @@ -920,6 +968,7 @@ impl BitAnd<&CliffordRep> for &CliffordRep { type Output = CliffordRep; fn bitand(self, rhs: &CliffordRep) -> CliffordRep { + assert_disjoint_clifford_support(self, rhs); self.compose(rhs) } } @@ -981,13 +1030,13 @@ impl From<&PauliString> for CliffordRep { /// Free-standing constructor functions for Clifford gates. /// -/// These mirror the `pecos_core::pauli::constructors` module, providing -/// ergonomic gate creation and composition via the `*` operator. +/// These are re-exported through `pecos_core::clifford`, providing ergonomic +/// gate creation and composition via the `*` operator. /// /// # Examples /// /// ``` -/// use pecos_core::clifford_rep::constructors::*; +/// use pecos_core::clifford::*; /// /// // H * SZ * H = SX (sqrt-X) /// let sx = H(0) * SZ(0) * H(0); @@ -1879,6 +1928,61 @@ mod tests { assert_eq!(cliff.apply(&z1), composed.apply(&z1)); } + #[test] + fn clifford_tensor_accepts_disjoint_support() { + let tensor = CliffordRep::h(0) & CliffordRep::sz(1); + + assert_eq!(tensor.apply(&PauliString::x(0)), PauliString::z(0)); + assert_eq!(tensor.apply(&PauliString::z(0)), PauliString::x(0)); + assert_eq!(tensor.apply(&PauliString::x(1)), PauliString::y(1)); + assert_eq!(tensor.apply(&PauliString::z(1)), PauliString::z(1)); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint Clifford support")] + fn clifford_tensor_rejects_overlapping_support() { + let _ = CliffordRep::h(0) & CliffordRep::sz(0); + } + + #[test] + fn support_qubits_omits_identity_spectators() { + assert!(CliffordRep::identity(10).support_qubits().is_empty()); + assert_eq!(CliffordRep::h(3).extended_to(10).support_qubits(), vec![3]); + assert_eq!( + CliffordRep::cx(0, 2).extended_to(5).support_qubits(), + vec![0, 2] + ); + } + + #[test] + fn clifford_tensor_accepts_interleaved_disjoint_support() { + let tensor = CliffordRep::cx(0, 2) & CliffordRep::h(1); + + assert!(tensor.is_valid()); + assert_eq!(tensor.support_qubits(), vec![0, 1, 2]); + assert_eq!( + tensor.apply(&PauliString::x(0)), + PauliString::x(0) & PauliString::x(2) + ); + assert_eq!( + tensor.apply(&PauliString::z(2)), + PauliString::z(0) & PauliString::z(2) + ); + assert_eq!(tensor.apply(&PauliString::x(1)), PauliString::z(1)); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint Clifford support")] + fn clifford_tensor_rejects_nonadjacent_partial_overlap() { + let _ = CliffordRep::cx(0, 2) & CliffordRep::z(2); + } + + #[test] + #[should_panic(expected = "SWAP requires distinct qubits")] + fn clifford_two_qubit_gate_rejects_repeated_qubit() { + let _ = CliffordRep::swap(2, 2); + } + #[test] fn test_clifford_enum_on_qubit() { use crate::clifford::Clifford; diff --git a/crates/pecos-core/src/gate.rs b/crates/pecos-core/src/gate.rs new file mode 100644 index 000000000..985c0cff3 --- /dev/null +++ b/crates/pecos-core/src/gate.rs @@ -0,0 +1,553 @@ +// 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. + +//! Ideal circuit-operation namespace. +//! +//! Constructors in this module return [`GateExpr`]. Use this namespace when +//! the operation is an intended ideal circuit operation, including unitary +//! gates, preparation, measurement, and reset: +//! +//! ``` +//! use pecos_core::gate::*; +//! +//! let layer = H(0) & MZ(1); +//! let sequence = PZ(0) * H(0) * MZ(0); +//! ``` +//! +//! For automatic promotion across all levels, use [`crate::op`]. For physical +//! noise and open-system maps, use [`crate::channel`]. + +use crate::op::Op; +use crate::qubit_support::overlapping_qubits; +use crate::unitary_rep::{QubitPairs, Qubits}; +use crate::{Angle64, PauliString, QubitId, UnitaryRep, op, unitary_rep}; +use std::ops::{BitAnd, Mul}; + +pub use crate::op::{Basis, GateExpr}; + +fn gate_from_op(op: Op) -> GateExpr { + op.into_gate() + .expect("gate namespace constructors are gate-convertible") +} + +/// Lifts a unitary expression to the ideal gate level. +#[must_use] +pub fn from_unitary(unitary: impl Into) -> GateExpr { + GateExpr::Unitary(unitary.into()) +} + +impl From for GateExpr { + fn from(unitary: UnitaryRep) -> Self { + GateExpr::Unitary(unitary) + } +} + +impl From for GateExpr { + fn from(pauli: PauliString) -> Self { + GateExpr::Unitary(UnitaryRep::from(pauli)) + } +} + +macro_rules! unitary_1q { + ($name:ident) => { + #[allow(non_snake_case)] + #[must_use] + pub fn $name(qubit: impl Into) -> GateExpr { + from_unitary(unitary_rep::$name(qubit)) + } + }; +} + +macro_rules! unitary_1q_plural { + ($name:ident) => { + #[allow(non_snake_case)] + #[must_use] + pub fn $name(qubits: impl Into) -> GateExpr { + from_unitary(unitary_rep::$name(qubits)) + } + }; +} + +macro_rules! op_1q { + ($name:ident) => { + #[allow(non_snake_case)] + #[must_use] + pub fn $name(qubit: impl Into) -> GateExpr { + gate_from_op(op::$name(qubit)) + } + }; +} + +macro_rules! op_2q { + ($name:ident) => { + #[allow(non_snake_case)] + #[must_use] + pub fn $name(q0: impl Into, q1: impl Into) -> GateExpr { + gate_from_op(op::$name(q0, q1)) + } + }; +} + +macro_rules! unitary_2q { + ($name:ident) => { + #[allow(non_snake_case)] + #[must_use] + pub fn $name(q0: impl Into, q1: impl Into) -> GateExpr { + from_unitary(unitary_rep::$name(q0, q1)) + } + }; +} + +macro_rules! unitary_2q_plural { + ($name:ident) => { + #[allow(non_snake_case)] + #[must_use] + pub fn $name(pairs: impl Into) -> GateExpr { + from_unitary(unitary_rep::$name(pairs)) + } + }; +} + +unitary_1q!(I); +unitary_1q_plural!(Is); +unitary_1q!(X); +unitary_1q_plural!(Xs); +unitary_1q!(Y); +unitary_1q_plural!(Ys); +unitary_1q!(Z); +unitary_1q_plural!(Zs); +unitary_1q!(H); +unitary_1q_plural!(Hs); +unitary_1q!(SX); +unitary_1q_plural!(SXs); +op_1q!(SXdg); +unitary_1q!(SY); +unitary_1q_plural!(SYs); +op_1q!(SYdg); +unitary_1q!(SZ); +unitary_1q_plural!(SZs); +op_1q!(SZdg); +op_1q!(H2); +op_1q!(H3); +op_1q!(H4); +op_1q!(H5); +op_1q!(H6); +op_1q!(F); +op_1q!(Fdg); +op_1q!(F2); +op_1q!(F2dg); +op_1q!(F3); +op_1q!(F3dg); +op_1q!(F4); +op_1q!(F4dg); +unitary_1q!(T); +unitary_1q_plural!(Ts); +op_1q!(Tdg); + +unitary_2q!(CX); +unitary_2q_plural!(CXs); +unitary_2q!(CY); +unitary_2q_plural!(CYs); +unitary_2q!(CZ); +unitary_2q_plural!(CZs); +unitary_2q!(SWAP); +unitary_2q_plural!(SWAPs); +op_2q!(SXX); +op_2q!(SXXdg); +op_2q!(SYY); +op_2q!(SYYdg); +unitary_2q!(SZZ); +unitary_2q_plural!(SZZs); +op_2q!(SZZdg); +op_2q!(ISWAP); +op_2q!(ISWAPdg); +op_2q!(G); +op_2q!(Gdg); + +/// Rotation around X axis: exp(-i theta/2 X). +#[allow(non_snake_case)] +#[must_use] +pub fn RX(angle: Angle64, qubit: impl Into) -> GateExpr { + from_unitary(unitary_rep::RX(angle, qubit)) +} + +/// Rotations around X on multiple qubits. +#[allow(non_snake_case)] +#[must_use] +pub fn RXs(angle: Angle64, qubits: impl Into) -> GateExpr { + from_unitary(unitary_rep::RXs(angle, qubits)) +} + +/// Rotation around Y axis: exp(-i theta/2 Y). +#[allow(non_snake_case)] +#[must_use] +pub fn RY(angle: Angle64, qubit: impl Into) -> GateExpr { + from_unitary(unitary_rep::RY(angle, qubit)) +} + +/// Rotations around Y on multiple qubits. +#[allow(non_snake_case)] +#[must_use] +pub fn RYs(angle: Angle64, qubits: impl Into) -> GateExpr { + from_unitary(unitary_rep::RYs(angle, qubits)) +} + +/// Rotation around Z axis: exp(-i theta/2 Z). +#[allow(non_snake_case)] +#[must_use] +pub fn RZ(angle: Angle64, qubit: impl Into) -> GateExpr { + from_unitary(unitary_rep::RZ(angle, qubit)) +} + +/// Rotations around Z on multiple qubits. +#[allow(non_snake_case)] +#[must_use] +pub fn RZs(angle: Angle64, qubits: impl Into) -> GateExpr { + from_unitary(unitary_rep::RZs(angle, qubits)) +} + +/// Two-qubit XX rotation: exp(-i theta/2 XX). +#[allow(non_snake_case)] +#[must_use] +pub fn RXX(angle: Angle64, q0: impl Into, q1: impl Into) -> GateExpr { + from_unitary(unitary_rep::RXX(angle, q0, q1)) +} + +/// XX rotations on multiple qubit pairs. +#[allow(non_snake_case)] +#[must_use] +pub fn RXXs(angle: Angle64, pairs: impl Into) -> GateExpr { + from_unitary(unitary_rep::RXXs(angle, pairs)) +} + +/// Two-qubit YY rotation: exp(-i theta/2 YY). +#[allow(non_snake_case)] +#[must_use] +pub fn RYY(angle: Angle64, q0: impl Into, q1: impl Into) -> GateExpr { + from_unitary(unitary_rep::RYY(angle, q0, q1)) +} + +/// YY rotations on multiple qubit pairs. +#[allow(non_snake_case)] +#[must_use] +pub fn RYYs(angle: Angle64, pairs: impl Into) -> GateExpr { + from_unitary(unitary_rep::RYYs(angle, pairs)) +} + +/// Two-qubit ZZ rotation: exp(-i theta/2 ZZ). +#[allow(non_snake_case)] +#[must_use] +pub fn RZZ(angle: Angle64, q0: impl Into, q1: impl Into) -> GateExpr { + from_unitary(unitary_rep::RZZ(angle, q0, q1)) +} + +/// ZZ rotations on multiple qubit pairs. +#[allow(non_snake_case)] +#[must_use] +pub fn RZZs(angle: Angle64, pairs: impl Into) -> GateExpr { + from_unitary(unitary_rep::RZZs(angle, pairs)) +} + +/// Toffoli gate (CCX). +#[allow(non_snake_case)] +#[must_use] +pub fn CCX(c0: impl Into, c1: impl Into, target: impl Into) -> GateExpr { + from_unitary(unitary_rep::CCX(c0, c1, target)) +} + +/// Prepare a qubit in the requested basis eigenstate. +#[must_use] +pub fn prep(basis: Basis, qubit: impl Into) -> GateExpr { + GateExpr::Prep { + basis, + qubit: qubit.into().0, + } +} + +/// Measure a qubit in the requested basis. +#[must_use] +pub fn measure(basis: Basis, qubit: impl Into) -> GateExpr { + GateExpr::Measure { + basis, + qubit: qubit.into().0, + } +} + +/// Reset a qubit to the requested basis eigenstate. +#[must_use] +pub fn reset(basis: Basis, qubit: impl Into) -> GateExpr { + GateExpr::Reset { + basis, + qubit: qubit.into().0, + } +} + +/// Prepare qubit in the |0> state. +#[allow(non_snake_case)] +#[must_use] +pub fn PZ(qubit: impl Into) -> GateExpr { + prep(Basis::Z, qubit) +} + +/// Prepare qubit in the |+> state. +#[allow(non_snake_case)] +#[must_use] +pub fn PX(qubit: impl Into) -> GateExpr { + prep(Basis::X, qubit) +} + +/// Prepare qubit in the Y-basis +1 eigenstate. +#[allow(non_snake_case)] +#[must_use] +pub fn PY(qubit: impl Into) -> GateExpr { + prep(Basis::Y, qubit) +} + +/// Measure qubit in the Z basis. +#[allow(non_snake_case)] +#[must_use] +pub fn MZ(qubit: impl Into) -> GateExpr { + measure(Basis::Z, qubit) +} + +/// Measure qubit in the X basis. +#[allow(non_snake_case)] +#[must_use] +pub fn MX(qubit: impl Into) -> GateExpr { + measure(Basis::X, qubit) +} + +/// Measure qubit in the Y basis. +#[allow(non_snake_case)] +#[must_use] +pub fn MY(qubit: impl Into) -> GateExpr { + measure(Basis::Y, qubit) +} + +/// Reset qubit to |0>. +#[allow(non_snake_case)] +#[must_use] +pub fn Reset(qubit: impl Into) -> GateExpr { + reset(Basis::Z, qubit) +} + +impl BitAnd for GateExpr { + type Output = GateExpr; + + fn bitand(self, rhs: GateExpr) -> GateExpr { + let overlap = overlapping_qubits(self.qubits(), rhs.qubits()); + assert!( + overlap.is_empty(), + "tensor product requires disjoint gate support; overlapping qubits: {overlap:?}" + ); + GateExpr::Tensor(vec![self, rhs]) + } +} + +impl BitAnd<&GateExpr> for GateExpr { + type Output = GateExpr; + + fn bitand(self, rhs: &GateExpr) -> GateExpr { + self & rhs.clone() + } +} + +impl BitAnd for &GateExpr { + type Output = GateExpr; + + fn bitand(self, rhs: GateExpr) -> GateExpr { + self.clone() & rhs + } +} + +impl BitAnd<&GateExpr> for &GateExpr { + type Output = GateExpr; + + fn bitand(self, rhs: &GateExpr) -> GateExpr { + self.clone() & rhs.clone() + } +} + +impl Mul for GateExpr { + type Output = GateExpr; + + fn mul(self, rhs: GateExpr) -> GateExpr { + GateExpr::Compose(vec![self, rhs]) + } +} + +impl Mul<&GateExpr> for GateExpr { + type Output = GateExpr; + + fn mul(self, rhs: &GateExpr) -> GateExpr { + self * rhs.clone() + } +} + +impl Mul for &GateExpr { + type Output = GateExpr; + + fn mul(self, rhs: GateExpr) -> GateExpr { + self.clone() * rhs + } +} + +impl Mul<&GateExpr> for &GateExpr { + type Output = GateExpr; + + fn mul(self, rhs: &GateExpr) -> GateExpr { + self.clone() * rhs.clone() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn namespace_constructors_return_gate_expr() { + assert!(matches!( + MZ(3), + GateExpr::Measure { + basis: Basis::Z, + qubit: 3 + } + )); + assert!(matches!( + MX(4), + GateExpr::Measure { + basis: Basis::X, + qubit: 4 + } + )); + assert!(matches!( + MY(5), + GateExpr::Measure { + basis: Basis::Y, + qubit: 5 + } + )); + assert!(matches!( + PZ(0), + GateExpr::Prep { + basis: Basis::Z, + qubit: 0 + } + )); + assert!(matches!( + PX(1), + GateExpr::Prep { + basis: Basis::X, + qubit: 1 + } + )); + assert!(matches!( + PY(2), + GateExpr::Prep { + basis: Basis::Y, + qubit: 2 + } + )); + } + + #[test] + fn unitary_constructor_lifts_to_gate_level() { + assert!(matches!(H(0), GateExpr::Unitary(_))); + assert!(matches!(T(0), GateExpr::Unitary(_))); + assert!(matches!(CX(0, 1), GateExpr::Unitary(_))); + assert_eq!(I(7).qubits(), vec![7]); + } + + #[test] + #[should_panic(expected = "CX requires distinct qubits")] + fn gate_namespace_two_qubit_gate_rejects_repeated_qubit() { + let _ = CX(0, 0); + } + + #[test] + #[should_panic(expected = "RZZ requires distinct qubits")] + fn gate_namespace_two_qubit_rotation_rejects_repeated_qubit() { + let _ = RZZ(Angle64::QUARTER_TURN, 1, 1); + } + + #[test] + #[should_panic(expected = "CCX requires distinct qubits")] + fn gate_namespace_three_qubit_gate_rejects_repeated_qubit() { + let _ = CCX(0, 1, 1); + } + + #[test] + fn gate_tensor_and_composition_stay_gate_level() { + let tensor = H(0) & MZ(1); + assert!(matches!(tensor, GateExpr::Tensor(parts) if parts.len() == 2)); + + let sequence = PZ(0) * H(0) * MZ(0); + assert!(matches!(sequence, GateExpr::Compose(parts) if parts.len() == 2)); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint gate support")] + fn gate_tensor_rejects_overlapping_qubits() { + let _ = H(0) & MZ(0); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint gate support")] + fn gate_tensor_rejects_partial_overlap_with_multi_qubit_support() { + let _ = CX(0, 2) & H(2); + } + + #[test] + fn gate_tensor_uses_sparse_support_not_dense_span() { + let tensor = CX(0, 2) & MZ(1); + assert!(matches!(tensor, GateExpr::Tensor(ref parts) if parts.len() == 2)); + assert_eq!(tensor.qubits(), vec![0, 1, 2]); + } + + #[test] + fn gate_namespace_plural_helpers_match_tensor_forms() { + let cxs = CXs([(0, 1), (2, 3)]); + assert!(matches!(cxs, GateExpr::Unitary(_))); + assert_eq!(cxs.qubits(), vec![0, 1, 2, 3]); + + let rzzs = RZZs(Angle64::QUARTER_TURN, [(0, 1), (2, 3)]); + assert!(matches!(rzzs, GateExpr::Unitary(_))); + assert_eq!(rzzs.qubits(), vec![0, 1, 2, 3]); + + let tensor = CX(0, 1) & CX(2, 3); + assert!(matches!(tensor, GateExpr::Tensor(_))); + assert_eq!(tensor.qubits(), vec![0, 1, 2, 3]); + } + + #[test] + fn gate_namespace_plural_helpers_reject_overlapping_support() { + fn assert_tensor_overlap_panic(f: impl FnOnce() + std::panic::UnwindSafe) { + let err = std::panic::catch_unwind(f).expect_err("expected tensor overlap panic"); + let message = err + .downcast_ref::() + .map(String::as_str) + .or_else(|| err.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!( + message.contains("tensor product requires disjoint"), + "unexpected panic message: {message}" + ); + } + + assert_tensor_overlap_panic(|| { + let _ = CXs([(0, 1), (1, 2)]); + }); + assert_tensor_overlap_panic(|| { + let _ = RZZs(Angle64::QUARTER_TURN, [(0, 2), (2, 3)]); + }); + } +} diff --git a/crates/pecos-core/src/gate_type.rs b/crates/pecos-core/src/gate_type.rs index fe3a03de9..2c2c94386 100644 --- a/crates/pecos-core/src/gate_type.rs +++ b/crates/pecos-core/src/gate_type.rs @@ -108,8 +108,24 @@ pub enum GateType { /// Free/deallocate a qubit QFree = 136, Idle = 200, + /// Meta-gate: tracked-Pauli annotation for fault tracking. + /// + /// This gate carries a Pauli string but has no effect on quantum state. + /// Its position in the circuit determines which faults can flip the tracked Pauli + /// (only faults before this node are relevant). The propagator uses it as a + /// backward propagation start point. + /// + /// The Pauli string is encoded in `params`: each param encodes + /// `qubit * 4 + pauli_type` where `pauli_type` is 1=X, 2=Y, 3=Z. + TrackedPauliMeta = 210, MeasCrosstalkGlobalPayload = 218, MeasCrosstalkLocalPayload = 219, + /// Typed channel operation embedded in an annotated/noisy circuit. + /// + /// The concrete channel payload is stored on [`crate::Gate`], not in the + /// numeric gate type. Ideal circuits should not contain this gate type; it + /// represents compiled noise annotations or explicit channel placement. + Channel = 220, /// Custom/unrecognized gate type, with actual name stored in metadata Custom = 255, } @@ -164,6 +180,8 @@ impl From for GateType { 200 => GateType::Idle, 218 => GateType::MeasCrosstalkGlobalPayload, 219 => GateType::MeasCrosstalkLocalPayload, + 210 => GateType::TrackedPauliMeta, + 220 => GateType::Channel, 255 => GateType::Custom, _ => panic!("Invalid gate type ID: {value}"), } @@ -171,6 +189,15 @@ impl From for GateType { } impl GateType { + /// Returns true if this gate type is a meta-gate (annotation, not physical). + /// + /// Meta-gates have a position in the DAG but do not affect quantum state + /// and should not create fault locations or receive noise. + #[must_use] + pub const fn is_meta(self) -> bool { + matches!(self, GateType::TrackedPauliMeta) + } + /// Returns the number of angle parameters this gate type requires /// /// # Returns @@ -212,10 +239,12 @@ impl GateType { | GateType::MeasureFree | GateType::MeasCrosstalkGlobalPayload | GateType::MeasCrosstalkLocalPayload + | GateType::Channel | GateType::PZ | GateType::QAlloc | GateType::QFree - | GateType::Custom => 0, + | GateType::Custom + | GateType::TrackedPauliMeta => 0, // Gates with one parameter GateType::RX @@ -277,7 +306,13 @@ impl GateType { | GateType::Idle | GateType::MeasCrosstalkGlobalPayload | GateType::MeasCrosstalkLocalPayload - | GateType::Custom => 1, + | 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, // Two-qubit gates GateType::CX @@ -303,6 +338,29 @@ impl GateType { } } + /// Returns the number of gates represented by a command with `qubit_count` + /// qubits. + /// + /// Most gate commands are batchable: a command with 4 qubits and arity 2 + /// represents two gates. Payload/meta gates are annotations, not physical + /// gates. Variable-arity custom/channel gates are counted as one + /// command-level gate. + #[must_use] + pub const fn num_gates(self, qubit_count: usize) -> usize { + if matches!( + self, + GateType::MeasCrosstalkGlobalPayload + | GateType::MeasCrosstalkLocalPayload + | GateType::TrackedPauliMeta + ) { + return 0; + } + if matches!(self, GateType::Custom | GateType::Channel) { + return 1; + } + qubit_count / self.quantum_arity() + } + /// Returns the number of angle parameters this gate type requires. /// /// This is separate from `classical_arity()` which includes all classical parameters. @@ -404,7 +462,9 @@ impl fmt::Display for GateType { GateType::Idle => write!(f, "Idle"), GateType::MeasCrosstalkGlobalPayload => write!(f, "MeasCrosstalkGlobalPayload"), GateType::MeasCrosstalkLocalPayload => write!(f, "MeasCrosstalkLocalPayload"), + GateType::Channel => write!(f, "Channel"), GateType::Custom => write!(f, "Custom"), + GateType::TrackedPauliMeta => write!(f, "TrackedPauli"), } } } @@ -466,6 +526,8 @@ impl std::str::FromStr for GateType { "QALLOC" => Ok(GateType::QAlloc), "QFREE" => Ok(GateType::QFree), "IDLE" => Ok(GateType::Idle), + "TRACKEDPAULI" | "TRACKEDPAULIMETA" | "TP" => Ok(GateType::TrackedPauliMeta), + "CHANNEL" => Ok(GateType::Channel), _ => Err(format!("Unknown gate type: {s}")), } } @@ -501,6 +563,7 @@ mod tests { assert_eq!(GateType::Idle as u8, 200); assert_eq!(GateType::MeasCrosstalkGlobalPayload as u8, 218); assert_eq!(GateType::MeasCrosstalkLocalPayload as u8, 219); + assert_eq!(GateType::Channel as u8, 220); assert_eq!(GateType::Custom as u8, 255); assert_eq!(GateType::from(0u8), GateType::I); @@ -527,6 +590,7 @@ mod tests { assert_eq!(GateType::from(200u8), GateType::Idle); assert_eq!(GateType::from(218u8), GateType::MeasCrosstalkGlobalPayload); assert_eq!(GateType::from(219u8), GateType::MeasCrosstalkLocalPayload); + assert_eq!(GateType::from(220u8), GateType::Channel); assert_eq!(GateType::from(255u8), GateType::Custom); } @@ -544,6 +608,7 @@ mod tests { assert_eq!(GateType::from_str("SXXdg").unwrap(), GateType::SXXdg); assert_eq!(GateType::from_str("SYY").unwrap(), GateType::SYY); assert_eq!(GateType::from_str("SYYdg").unwrap(), GateType::SYYdg); + 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); @@ -590,6 +655,7 @@ mod tests { assert_eq!(GateType::MeasureFree.classical_arity(), 0); assert_eq!(GateType::MeasCrosstalkGlobalPayload.classical_arity(), 0); assert_eq!(GateType::MeasCrosstalkLocalPayload.classical_arity(), 0); + assert_eq!(GateType::Channel.classical_arity(), 0); assert_eq!(GateType::PZ.classical_arity(), 0); assert_eq!(GateType::QAlloc.classical_arity(), 0); assert_eq!(GateType::QFree.classical_arity(), 0); @@ -626,6 +692,7 @@ mod tests { assert_eq!(GateType::Idle.quantum_arity(), 1); assert_eq!(GateType::MeasCrosstalkGlobalPayload.quantum_arity(), 1); assert_eq!(GateType::MeasCrosstalkLocalPayload.quantum_arity(), 1); + assert_eq!(GateType::Channel.quantum_arity(), 1); // Two-qubit gates assert_eq!(GateType::CX.quantum_arity(), 2); @@ -634,6 +701,41 @@ mod tests { assert_eq!(GateType::RZZ.quantum_arity(), 2); } + #[test] + fn test_num_gates() { + assert_eq!(GateType::H.num_gates(4), 4); + assert_eq!(GateType::CX.num_gates(4), 2); + assert_eq!(GateType::CCX.num_gates(6), 2); + assert_eq!(GateType::Custom.num_gates(2), 1); + assert_eq!(GateType::Channel.num_gates(2), 1); + assert_eq!(GateType::TrackedPauliMeta.num_gates(3), 0); + assert_eq!(GateType::MeasCrosstalkGlobalPayload.num_gates(3), 0); + assert_eq!(GateType::MeasCrosstalkLocalPayload.num_gates(3), 0); + } + + #[test] + fn test_tracked_pauli_meta_gate_type_contract() { + assert_eq!( + "TrackedPauli".parse::().unwrap(), + GateType::TrackedPauliMeta + ); + assert_eq!( + "TrackedPauliMeta".parse::().unwrap(), + GateType::TrackedPauliMeta + ); + assert_eq!( + "TP".parse::().unwrap(), + GateType::TrackedPauliMeta + ); + + assert_eq!(GateType::TrackedPauliMeta.to_string(), "TrackedPauli"); + assert_eq!(GateType::TrackedPauliMeta as u8, 210); + assert!(GateType::TrackedPauliMeta.is_meta()); + assert_eq!(GateType::TrackedPauliMeta.classical_arity(), 0); + assert_eq!(GateType::TrackedPauliMeta.quantum_arity(), 1); + assert_eq!(GateType::TrackedPauliMeta.num_gates(4), 0); + } + #[test] fn test_is_parameterized() { // Non-parameterized gates diff --git a/crates/pecos-core/src/gates.rs b/crates/pecos-core/src/gates.rs index c48fa72a3..c03830714 100644 --- a/crates/pecos-core/src/gates.rs +++ b/crates/pecos-core/src/gates.rs @@ -4,8 +4,11 @@ //! gate operation with its type, qubits, and parameters. use crate::Angle64; +use crate::ChannelExpr; +use crate::MeasId; use crate::QubitId; use crate::gate_type::GateType; +use crate::qubit_support::duplicate_qubits; use smallvec::SmallVec; /// Stack-allocated qubit buffer for gates (up to 4 qubits inline). @@ -20,6 +23,10 @@ pub type GateAngles = SmallVec<[Angle64; 3]>; /// Most gates have 0-1 non-angle parameters. pub type GateParams = SmallVec<[f64; 2]>; +/// Measurement result identities for measurement gates. +/// Empty for non-measurement gates. One entry per qubit for MZ/MX/MY. +pub type GateMeasIds = SmallVec<[MeasId; 1]>; + /// Flat gate command representation for quantum operations /// /// Clean, flat representation of quantum gate commands @@ -45,6 +52,17 @@ pub struct Gate { /// The qubits the gate acts on. /// Stack-allocated for up to 4 qubits. pub qubits: GateQubits, + /// Measurement result identities (one per qubit for measurement gates). + /// + /// Assigned at circuit construction time, carried through all + /// transformations. Empty for non-measurement gates. + /// Follows the MLIR SSA pattern: defined once, referenced everywhere. + pub meas_ids: GateMeasIds, + /// Typed channel payload for `GateType::Channel`. + /// + /// This is `None` for ideal circuit gates. It is populated only for + /// annotated/noisy circuits that explicitly carry channel operations. + pub channel: Option, } /// Legacy quantum gate representation for `ByteMessageBuilder` compatibility @@ -67,6 +85,8 @@ impl Gate { angles: angles.into(), params: params.into(), qubits: qubits.into(), + meas_ids: GateMeasIds::new(), + channel: None, } } @@ -97,7 +117,66 @@ impl Gate { #[inline] #[must_use] pub fn num_gates(&self) -> usize { - self.num_qubits() / self.quantum_arity() + self.gate_type.num_gates(self.num_qubits()) + } + + /// Returns true if `self` and `other` can be represented as one batched gate + /// command by concatenating qubit and measurement-id payloads. + /// + /// Batch-compatible gates are identical except for disjoint qubit support + /// and, for measurement gates, their corresponding measurement ids. + #[must_use] + pub fn can_batch_with(&self, other: &Self) -> bool { + if self.gate_type != other.gate_type + || self.angles != other.angles + || self.params != other.params + || self.channel != other.channel + { + return false; + } + + if matches!( + self.gate_type, + GateType::Custom + | GateType::Channel + | GateType::TrackedPauliMeta + | GateType::MeasCrosstalkGlobalPayload + | GateType::MeasCrosstalkLocalPayload + ) { + return false; + } + + if self.qubits.iter().any(|q| other.qubits.contains(q)) { + return false; + } + + let self_has_meas_ids = !self.meas_ids.is_empty(); + let other_has_meas_ids = !other.meas_ids.is_empty(); + if self_has_meas_ids != other_has_meas_ids { + return false; + } + if self_has_meas_ids + && (self.meas_ids.len() != self.qubits.len() + || other.meas_ids.len() != other.qubits.len()) + { + return false; + } + + true + } + + /// Appends a compatible gate command into this batch. + /// + /// # Panics + /// + /// Panics if `other` is not batch-compatible with `self`. + pub fn append_batch(&mut self, other: Self) { + assert!( + self.can_batch_with(&other), + "cannot batch incompatible gate commands" + ); + self.qubits.extend(other.qubits); + self.meas_ids.extend(other.meas_ids); } /// Helper function to flatten qubit pairs into a `GateQubits` buffer @@ -116,6 +195,36 @@ impl Gate { Self::simple(GateType::Custom, qubits) } + /// Create a typed channel operation for an annotated/noisy circuit. + #[must_use] + pub fn channel(channel: ChannelExpr) -> Self { + let qubits = channel + .qubits() + .into_iter() + .map(QubitId) + .collect::(); + Self { + gate_type: GateType::Channel, + angles: GateAngles::new(), + params: GateParams::new(), + qubits, + meas_ids: GateMeasIds::new(), + channel: Some(channel), + } + } + + /// Returns the typed channel payload when this is a channel operation. + #[must_use] + pub fn channel_expr(&self) -> Option<&ChannelExpr> { + self.channel.as_ref() + } + + /// Returns true when this gate carries a channel payload. + #[must_use] + pub fn is_channel(&self) -> bool { + self.gate_type == GateType::Channel + } + /// Create Identity gate on multiple qubits #[must_use] pub fn i(qubits: &[impl Into + Copy]) -> Self { @@ -917,6 +1026,9 @@ impl Gate { #[inline] #[must_use] pub fn classical_arity(&self) -> usize { + if self.is_channel() { + return 0; + } self.gate_type.classical_arity() } @@ -928,6 +1040,9 @@ impl Gate { #[inline] #[must_use] pub fn quantum_arity(&self) -> usize { + if self.is_channel() { + return self.qubits.len().max(1); + } self.gate_type.quantum_arity() } @@ -935,6 +1050,9 @@ impl Gate { #[inline] #[must_use] pub fn is_parameterized(&self) -> bool { + if self.is_channel() { + return false; + } self.gate_type.is_parameterized() } @@ -942,6 +1060,9 @@ impl Gate { #[inline] #[must_use] pub fn is_single_qubit(&self) -> bool { + if self.is_channel() { + return self.qubits.len() == 1; + } self.gate_type.is_single_qubit() } @@ -949,6 +1070,9 @@ impl Gate { #[inline] #[must_use] pub fn is_two_qubit(&self) -> bool { + if self.is_channel() { + return self.qubits.len() == 2; + } self.gate_type.is_two_qubit() } @@ -956,6 +1080,9 @@ impl Gate { #[inline] #[must_use] pub fn angle_arity(&self) -> usize { + if self.is_channel() { + return 0; + } self.gate_type.angle_arity() } @@ -970,7 +1097,47 @@ impl Gate { /// Returns an error if: /// - The number of angles doesn't match the gate's angle arity /// - The number of qubits is not a multiple of the gate's quantum arity + /// - Any qubit is repeated within the gate command pub fn validate(&self) -> Result<(), String> { + if self.is_channel() { + let Some(channel) = &self.channel else { + return Err("GateType::Channel requires a channel payload".to_string()); + }; + if !self.angles.is_empty() || !self.params.is_empty() || !self.meas_ids.is_empty() { + return Err( + "Channel gates cannot carry angle, parameter, or measurement-id payloads" + .to_string(), + ); + } + let expected = channel + .qubits() + .into_iter() + .map(QubitId) + .collect::(); + if self.qubits != expected { + return Err(format!( + "Channel gate qubits {:?} do not match channel payload qubits {:?}", + self.qubits, expected + )); + } + return Ok(()); + } + if self.channel.is_some() { + return Err("Only GateType::Channel can carry a channel payload".to_string()); + } + if self.gate_type == GateType::Custom { + let duplicates = duplicate_qubits(self.qubits.iter().map(|q| q.0)); + if !duplicates.is_empty() { + return Err(format!( + "Gate {:?} requires distinct qubits within one gate command; duplicated qubits: {:?}", + self.gate_type, duplicates + )); + } + if !self.meas_ids.is_empty() { + return Err("Custom gates cannot carry measurement-id payloads".to_string()); + } + return Ok(()); + } // Check angle parameters if self.angles.len() != self.angle_arity() { return Err(format!( @@ -989,6 +1156,41 @@ impl Gate { self.qubits.len() )); } + let expected_params = self.classical_arity() - self.angle_arity(); + if self.params.len() != expected_params { + return Err(format!( + "Gate {:?} expected {} non-angle parameters, got {}", + self.gate_type, + expected_params, + self.params.len() + )); + } + let duplicates = duplicate_qubits(self.qubits.iter().map(|q| q.0)); + if !duplicates.is_empty() { + return Err(format!( + "Gate {:?} requires distinct qubits within one gate command; duplicated qubits: {:?}", + self.gate_type, duplicates + )); + } + let is_measurement = matches!( + self.gate_type, + GateType::MZ | GateType::MeasureLeaked | GateType::MeasureFree + ); + if is_measurement { + if !self.meas_ids.is_empty() && self.meas_ids.len() != self.qubits.len() { + return Err(format!( + "Measurement gate {:?} expected measurement-id count to be 0 or {}, got {}", + self.gate_type, + self.qubits.len(), + self.meas_ids.len() + )); + } + } else if !self.meas_ids.is_empty() { + return Err(format!( + "Gate {:?} cannot carry measurement-id payloads", + self.gate_type + )); + } Ok(()) } } @@ -1041,6 +1243,83 @@ mod tests { assert!(measure_gate.angles.is_empty()); } + #[test] + fn test_channel_gate_creation_and_validation() { + use crate::channel::{Dephasing, Depolarizing}; + + let gate = Gate::channel(Depolarizing(0.25, 0)); + assert_eq!(gate.gate_type, GateType::Channel); + assert_eq!(gate.qubits.as_slice(), &[QubitId::from(0)]); + assert!(gate.channel_expr().is_some()); + assert!(gate.validate().is_ok()); + + let two_qubit_channel = Depolarizing(0.1, 0) & Dephasing(0.2, 1); + let two_qubit_gate = Gate::channel(two_qubit_channel); + assert_eq!( + two_qubit_gate.qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + assert_eq!(two_qubit_gate.quantum_arity(), 2); + assert_eq!(two_qubit_gate.num_gates(), 1); + assert!(two_qubit_gate.is_two_qubit()); + assert!(two_qubit_gate.validate().is_ok()); + } + + #[test] + fn test_num_gates_counts_batched_gates() { + assert_eq!(Gate::h(&[0, 1, 2, 3]).num_gates(), 4); + assert_eq!(Gate::cx(&[(0, 1), (2, 3)]).num_gates(), 2); + assert_eq!(Gate::ccx(&[(0, 1, 2), (3, 4, 5)]).num_gates(), 2); + + assert_eq!( + Gate::custom(vec![QubitId::from(0), QubitId::from(1)]).num_gates(), + 1 + ); + assert_eq!( + Gate::simple( + GateType::TrackedPauliMeta, + vec![QubitId::from(0), QubitId::from(1)] + ) + .num_gates(), + 0 + ); + assert_eq!(Gate::meas_crosstalk_global_payload(&[0, 1]).num_gates(), 0); + } + + #[test] + fn test_gate_batch_compatibility_and_append() { + let mut h0 = Gate::h(&[0]); + let h1 = Gate::h(&[1]); + assert!(h0.can_batch_with(&h1)); + h0.append_batch(h1); + assert_eq!(h0.qubits.as_slice(), &[QubitId::from(0), QubitId::from(1)]); + assert_eq!(h0.num_gates(), 2); + + assert!(!Gate::h(&[0]).can_batch_with(&Gate::h(&[0]))); + assert!( + !Gate::rz(Angle64::from_turns(0.25), &[0]) + .can_batch_with(&Gate::rz(Angle64::from_turns(0.5), &[1])) + ); + assert!( + !Gate::custom(vec![QubitId::from(0)]) + .can_batch_with(&Gate::custom(vec![QubitId::from(1)])) + ); + } + + #[test] + fn test_measurement_batch_compatibility_preserves_measurement_ids() { + let mut m0 = Gate::mz(&[0]); + m0.meas_ids.push(MeasId(4)); + let mut m1 = Gate::mz(&[1]); + m1.meas_ids.push(MeasId(5)); + + assert!(m0.can_batch_with(&m1)); + m0.append_batch(m1); + + assert_eq!(m0.qubits.as_slice(), &[QubitId::from(0), QubitId::from(1)]); + assert_eq!(m0.meas_ids.as_slice(), &[MeasId(4), MeasId(5)]); + } + #[test] fn test_two_qubit_gate_vec_variants() { // Test CX with _vec variant - much more convenient when you have a flat list @@ -1079,6 +1358,45 @@ mod tests { let _ = Gate::cx_vec(&[0, 1, 2]); } + #[test] + fn test_gate_validate_rejects_repeated_qubits_within_pair() { + let err = Gate::cx(&[(0, 0)]).validate().unwrap_err(); + assert!(err.contains("requires distinct qubits")); + assert!(err.contains("[0]")); + } + + #[test] + fn test_gate_validate_rejects_repeated_qubits_across_batched_pairs() { + let err = Gate::swap(&[(0, 1), (1, 2)]).validate().unwrap_err(); + assert!(err.contains("requires distinct qubits")); + assert!(err.contains("[1]")); + } + + #[test] + fn test_gate_validate_rejects_repeated_qubits_in_three_qubit_gate() { + let err = Gate::ccx(&[(0, 1, 1)]).validate().unwrap_err(); + assert!(err.contains("requires distinct qubits")); + assert!(err.contains("[1]")); + } + + #[test] + fn test_gate_validate_rejects_repeated_qubits_in_parameterized_two_qubit_gates() { + let angle = Angle64::from_turns(0.25); + let kak_angles = [[Angle64::ZERO; 3]; 2]; + let interaction = [Angle64::ZERO; 3]; + + let cases = [ + Gate::rzz(angle, &[(2, 2)]), + Gate::rxxryyrzz(angle, angle, angle, &[(3, 3)]), + Gate::u2q(kak_angles, interaction, kak_angles, &[(4, 4)]), + ]; + + for gate in cases { + let err = gate.validate().unwrap_err(); + assert!(err.contains("requires distinct qubits"), "{err}"); + } + } + #[test] #[should_panic(expected = "SZZ gate requires an even number of qubits")] fn test_szz_vec_odd_qubits() { @@ -1291,4 +1609,83 @@ mod tests { ); assert!(multi_cx_gates.validate().is_ok()); // Multiple CX gates } + + #[test] + fn test_gate_validation_rejects_duplicate_qubits_for_batched_commands() { + let duplicate_x = Gate::x(&[0, 0]); + let err = duplicate_x.validate().unwrap_err(); + assert!(err.contains("requires distinct qubits")); + assert!(err.contains("[0]")); + + let duplicate_mz = Gate::mz(&[1, 1]); + let err = duplicate_mz.validate().unwrap_err(); + assert!(err.contains("requires distinct qubits")); + assert!(err.contains("[1]")); + } + + #[test] + fn test_gate_validation_checks_non_angle_parameters_and_measurement_ids() { + let missing_idle_duration = Gate::new( + GateType::Idle, + Vec::::new(), + Vec::::new(), + vec![QubitId::from(0)], + ); + assert!( + missing_idle_duration + .validate() + .unwrap_err() + .contains("expected 1 non-angle parameters, got 0") + ); + + let mut measured = Gate::mz(&[0, 1]); + measured.meas_ids.push(MeasId(0)); + assert!( + measured + .validate() + .unwrap_err() + .contains("expected measurement-id count to be 0 or 2, got 1") + ); + + let mut non_measurement = Gate::x(&[0]); + non_measurement.meas_ids.push(MeasId(0)); + assert!( + non_measurement + .validate() + .unwrap_err() + .contains("cannot carry measurement-id payloads") + ); + } + + #[test] + fn test_channel_gate_validation_rejects_stale_payloads() { + use crate::channel::{BitFlip, Depolarizing}; + + let mut stale_qubits = Gate::channel(Depolarizing(0.25, 0)); + stale_qubits.qubits = vec![QubitId::from(1)].into(); + assert!( + stale_qubits + .validate() + .unwrap_err() + .contains("do not match channel payload qubits") + ); + + let mut stale_angles = Gate::channel(BitFlip(0.1, 0)); + stale_angles.angles.push(Angle64::from_turns(0.25)); + assert!( + stale_angles + .validate() + .unwrap_err() + .contains("cannot carry angle, parameter, or measurement-id payloads") + ); + + let mut channel_payload_on_ideal_gate = Gate::x(&[0]); + channel_payload_on_ideal_gate.channel = Some(BitFlip(0.1, 0)); + assert!( + channel_payload_on_ideal_gate + .validate() + .unwrap_err() + .contains("Only GateType::Channel can carry a channel payload") + ); + } } diff --git a/crates/pecos-core/src/lib.rs b/crates/pecos-core/src/lib.rs index 7007492af..68127d0d4 100644 --- a/crates/pecos-core/src/lib.rs +++ b/crates/pecos-core/src/lib.rs @@ -27,10 +27,12 @@ pub mod gate_registry; pub mod gate_type; pub mod gates; pub mod index_set; +pub mod meas_id; pub mod pauli; pub mod phase; pub mod prelude; pub mod qubit_id; +mod qubit_support; pub mod rng; pub mod sets; pub mod signal; @@ -46,6 +48,7 @@ pub use bitset::BitSet; pub use duration::{TimeScale, TimeUnits}; pub use element::Element; pub use index_set::IndexSet; +pub use meas_id::MeasId; pub use phase::GlobalPhase; pub use phase::quarter_phase::QuarterPhase; pub use phase::sign::Sign; @@ -69,8 +72,12 @@ pub use gate_registry::{ AngleSource, ConcreteStep, DecompStep, GateDefinition, GateDefinitionBuilder, GateRegistry, GateSignature, }; -pub use gates::{Gate, GateAngles, GateParams, GateQubits}; +pub use gates::{Gate, GateAngles, GateMeasIds, GateParams, GateQubits}; pub use pauli::pauli_bitmap::PauliBitmap; +pub use pauli::pauli_bitmask::{ + BitmaskStorage, Conjugated, PauliBitmask, PauliBitmaskGeneric, PauliBitmaskSmall, + PauliBitmaskVec, +}; pub use pauli::pauli_sparse::PauliSparse; pub use pauli::pauli_string::{ParsePauliStringError, PauliString}; pub use pauli::{Pauli, PauliOperator}; @@ -84,22 +91,32 @@ pub use circuit_diagram::{ DiagramStyleBuilder, FamilyPalette, FillPattern, GraphStyle, GraphStyleBuilder, blend_hex, }; -// UnitaryRep algebra +// --- Algebraic-level namespaces --- +// +// Each level is a module whose glob import gives the user the constructors and +// types for that algebraic level: +// +// use pecos_core::pauli::*; // I, X, Y, Z, Xs, Ys, Zs -> PauliString +// use pecos_core::clifford::*; // H, CX, CZ, SWAP, ... -> CliffordRep +// use pecos_core::unitary::*; // T, RZ, CCX, ... -> UnitaryRep +// use pecos_core::gate::*; // MZ, PZ, Reset, ... -> GateExpr +// use pecos_core::channel::*; // Depolarizing, PauliChannel, ... -> ChannelExpr +// use pecos_core::op::*; // MZ, PZ, Depolarizing, ... -> Op (promoted) + +pub mod unitary; pub use unitary_rep::{Is, Unitary, UnitaryRep}; -// PauliString constructors (primary user-facing API for Pauli algebra) pub use pauli::constructors::{I, X, Xs, Y, Ys, Z, Zs}; -// Clifford base type (single-qubit Clifford group element) pub mod clifford; pub use clifford::Clifford; -// Cross-type algebraic operators (Pauli * Clifford -> CliffordRep, etc.) +pub mod channel; +pub mod gate; pub mod gate_algebra; -// Unified gate algebra with automatic type promotion pub mod op; -pub use op::{Basis, ChannelExpr, Level, Op}; +pub use op::{Basis, ChannelExpr, GateExpr, Level, Op}; // Signals pub use signal::Signal; diff --git a/crates/pecos-core/src/meas_id.rs b/crates/pecos-core/src/meas_id.rs new file mode 100644 index 000000000..4a6f71fb0 --- /dev/null +++ b/crates/pecos-core/src/meas_id.rs @@ -0,0 +1,68 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Measurement result identity. +//! +//! Each measurement gate (MZ, MX, etc.) produces a `MeasId` — a unique +//! identifier for that measurement's outcome. Assigned once at circuit +//! construction time, carried through all transformations (`TickCircuit` → +//! `DagCircuit` → `InfluenceMap` → DEM). Never reassigned. +//! +//! This follows the MLIR SSA pattern: the value is defined at one point +//! and referenced everywhere. Detectors reference `MeasId` values +//! directly instead of fragile position-dependent offsets. +//! +//! Metadata (qubit, basis, coordinates, labels) lives in a side table, +//! not on the `MeasId` itself. The hot path (DEM builder, sampler, +//! decoder) works with `MeasId` only. + +use std::fmt; + +/// Unique identity of a measurement result. +/// +/// Lightweight (pointer-sized), `Copy`, directly usable as an array index. +/// Analogous to [`QubitId`](crate::QubitId) but for measurement outcomes. +/// +/// # Example +/// +/// ``` +/// use pecos_core::MeasId; +/// +/// let m0 = MeasId(0); +/// let m1 = MeasId(1); +/// assert_ne!(m0, m1); +/// +/// // Direct array indexing +/// let mut outcomes = vec![false; 10]; +/// outcomes[m0.0] = true; +/// ``` +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct MeasId(pub usize); + +impl MeasId { + /// The underlying index. + #[inline] + #[must_use] + pub fn index(self) -> usize { + self.0 + } +} + +impl fmt::Display for MeasId { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "m{}", self.0) + } +} + +impl From for MeasId { + fn from(v: usize) -> Self { + Self(v) + } +} + +impl From for usize { + fn from(m: MeasId) -> Self { + m.0 + } +} diff --git a/crates/pecos-core/src/op.rs b/crates/pecos-core/src/op.rs index 30a967c2e..0062d7c8e 100644 --- a/crates/pecos-core/src/op.rs +++ b/crates/pecos-core/src/op.rs @@ -12,20 +12,20 @@ //! Unified quantum operation algebra with automatic type promotion. //! -//! [`Op`] wraps four algebraic levels — [`PauliString`], [`CliffordRep`], -//! [`UnitaryRep`], and [`Channel`] — and automatically promotes to the +//! [`Op`] wraps five algebraic levels — [`PauliString`], [`CliffordRep`], +//! [`UnitaryRep`], [`GateExpr`], and [`ChannelExpr`] — and automatically promotes to the //! tightest level that can represent a combination. //! //! # Promotion Hierarchy //! //! ```text -//! Pauli ⊂ Clifford ⊂ Unitary ⊂ Channel +//! Pauli ⊂ Clifford ⊂ Unitary ⊂ Gate ⊂ Channel //! ``` //! //! Combining two `Op` values via tensor (`&`) or composition (`*`) promotes //! to the maximum level of the operands. The first three levels support full -//! algebraic operations including adjoint (`dg()`). The Channel level supports -//! tensor and composition but not adjoint. +//! algebraic operations including adjoint (`dg()`). The Gate and Channel levels +//! support tensor and composition but not adjoint. //! //! # Examples //! @@ -44,12 +44,17 @@ //! let u = X(0) & H(3) & T(5); //! assert!(u.is_unitary()); //! -//! // Adding a measurement promotes to Channel -//! let ch = H(0) & MZ(1); +//! // Adding a measurement promotes to Gate +//! let g = H(0) & MZ(1); +//! assert!(g.is_gate()); +//! +//! // Adding a noise channel promotes to Channel +//! let ch = g & Depolarizing(0.01, 2); //! assert!(ch.is_channel()); //! ``` use crate::clifford_rep::CliffordRep; +use crate::qubit_support::{assert_distinct_qubits, overlapping_qubits}; use crate::unitary_rep::{PhaseValue, UnitaryRep}; use crate::{Angle64, PauliString, QubitId}; use std::fmt; @@ -61,7 +66,7 @@ pub use crate::unitary_rep::phase; /// Unified quantum operation with automatic level promotion. /// -/// Wraps one of four algebraic levels and promotes to the tightest +/// Wraps one of five algebraic levels and promotes to the tightest /// level when combined via `&` (tensor) or `*` (composition). /// /// The Clifford variant stores both a [`CliffordRep`] (for efficient Clifford @@ -74,7 +79,10 @@ pub enum Op { Clifford(CliffordRep, UnitaryRep), /// General unitary level: expression tree. Unitary(UnitaryRep), - /// Channel level: non-unitary quantum operations (measurements, preparations). + /// Gate level: ideal circuit operations such as unitary gates, preparation, + /// measurement, and reset. + Gate(GateExpr), + /// Channel level: general CPTP maps and noise/decoherence operations. Channel(ChannelExpr), } @@ -84,22 +92,69 @@ pub enum Level { Pauli = 0, Clifford = 1, Unitary = 2, - Channel = 3, + Gate = 3, + Channel = 4, +} + +/// Error returned by fallible tensor-product constructors when supports overlap. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TensorProductError { + overlapping_qubits: Vec, } -/// A non-unitary quantum operation expression. +impl TensorProductError { + /// Qubits touched by both operands. + #[must_use] + pub fn overlapping_qubits(&self) -> &[usize] { + &self.overlapping_qubits + } +} + +impl fmt::Display for TensorProductError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "tensor product requires disjoint operator support; overlapping qubits: {:?}", + self.overlapping_qubits + ) + } +} + +impl std::error::Error for TensorProductError {} + +/// An ideal circuit operation expression. /// -/// Channels include measurements, preparations, noise channels (Kraus -/// operators), and their compositions. They compose and tensor like -/// unitaries but are not invertible. +/// Gate expressions represent operations that can appear in an ideal circuit +/// block: unitaries, preparations, measurements, resets, and their tensor or +/// sequential combinations. Measurement record allocation is owned by the +/// surrounding circuit representation, not by this expression. #[derive(Debug, Clone, PartialEq)] -pub enum ChannelExpr { +pub enum GateExpr { + /// A unitary operation lifted to the gate level. + Unitary(UnitaryRep), /// Prepare qubit in a given basis eigenstate. Prep { basis: Basis, qubit: usize }, - /// Measure qubit (produces classical bit). + /// Measure qubit in a given basis (produces a classical outcome). Measure { basis: Basis, qubit: usize }, + /// Reset qubit to the given basis eigenstate. + Reset { basis: Basis, qubit: usize }, + /// Tensor product of gate expressions. + Tensor(Vec), + /// Sequential composition: apply first element, then second, etc. + Compose(Vec), +} + +/// A general quantum channel expression. +/// +/// Channels include noise/decoherence maps, mixed unitaries, lifted ideal +/// gates, and their compositions. They compose and tensor like unitaries but +/// are not generally invertible. +#[derive(Debug, Clone, PartialEq)] +pub enum ChannelExpr { /// A unitary operation lifted to the channel level. Unitary(UnitaryRep), + /// An ideal gate expression lifted to the channel level. + Gate(GateExpr), /// Mixed-unitary channel: ρ → `Σ_k` `p_k` `U_k` ρ `U_k`†. /// /// Each entry is `(probability, unitary)` with probabilities summing to 1. @@ -121,10 +176,6 @@ pub enum ChannelExpr { /// replaced by the maximally mixed state and an erasure flag is raised. /// This is a heralded error — the location of the error is known. Erasure { prob: f64, qubit: usize }, - /// Reset channel: ρ → |0⟩⟨0| regardless of input state. - /// - /// Kraus operators: K₀ = |0⟩⟨0|, K₁ = |0⟩⟨1|. - Reset { qubit: usize }, /// Leakage channel: qubit transitions to a non-computational state /// with probability `rate`. /// @@ -155,6 +206,15 @@ fn cliff(cr: CliffordRep, ur: UnitaryRep) -> Op { Op::Clifford(cr, ur) } +fn require_disjoint_support(lhs: &[usize], rhs: &[usize]) -> Result<(), TensorProductError> { + let overlapping_qubits = overlapping_qubits(lhs.iter().copied(), rhs.iter().copied()); + if overlapping_qubits.is_empty() { + Ok(()) + } else { + Err(TensorProductError { overlapping_qubits }) + } +} + // --- Core methods --- impl Op { @@ -165,6 +225,7 @@ impl Op { Op::Pauli(_) => Level::Pauli, Op::Clifford(..) => Level::Clifford, Op::Unitary(_) => Level::Unitary, + Op::Gate(_) => Level::Gate, Op::Channel(_) => Level::Channel, } } @@ -184,11 +245,25 @@ impl Op { matches!(self, Op::Unitary(_)) } + #[must_use] + pub fn is_gate(&self) -> bool { + matches!(self, Op::Gate(_)) + } + #[must_use] pub fn is_channel(&self) -> bool { matches!(self, Op::Channel(_)) } + /// Extracts the inner `GateExpr`, if at the Gate level. + #[must_use] + pub fn as_gate(&self) -> Option<&GateExpr> { + match self { + Op::Gate(gate) => Some(gate), + _ => None, + } + } + /// Extracts the inner `ChannelExpr`, if at the Channel level. #[must_use] pub fn as_channel(&self) -> Option<&ChannelExpr> { @@ -235,34 +310,48 @@ impl Op { } /// Consumes and returns the inner `CliffordRep`. - /// Pauli promotes to Clifford. Returns `None` for Unitary/Channel (cannot demote). + /// Pauli promotes to Clifford. Returns `None` for Unitary/Gate/Channel (cannot demote). #[must_use] pub fn into_clifford(self) -> Option { match self { Op::Pauli(ps) => Some(CliffordRep::from(ps)), Op::Clifford(cr, _) => Some(cr), - Op::Unitary(_) | Op::Channel(_) => None, + Op::Unitary(_) | Op::Gate(_) | Op::Channel(_) => None, } } /// Consumes and returns a `UnitaryRep`. - /// Returns `None` for Channel (cannot demote). + /// Returns `None` for Gate/Channel (cannot demote). #[must_use] pub fn into_unitary(self) -> Option { match self { Op::Pauli(ps) => Some(UnitaryRep::from(ps)), Op::Clifford(_, ur) | Op::Unitary(ur) => Some(ur), + Op::Gate(_) | Op::Channel(_) => None, + } + } + + /// Consumes and returns a `GateExpr`. Unitary and lower levels promote to + /// `GateExpr::Unitary`; Channel cannot demote. + #[must_use] + pub fn into_gate(self) -> Option { + match self { + Op::Pauli(ps) => Some(GateExpr::Unitary(UnitaryRep::from(ps))), + Op::Clifford(_, ur) | Op::Unitary(ur) => Some(GateExpr::Unitary(ur)), + Op::Gate(gate) => Some(gate), Op::Channel(_) => None, } } /// Consumes and returns a `ChannelExpr`. Always succeeds: - /// lower levels promote to `ChannelExpr::Unitary`. + /// unitary and lower levels promote to `ChannelExpr::Unitary`; Gate + /// promotes to `ChannelExpr::Gate`. #[must_use] pub fn into_channel(self) -> ChannelExpr { match self { Op::Pauli(ps) => ChannelExpr::Unitary(UnitaryRep::from(ps)), Op::Clifford(_, ur) | Op::Unitary(ur) => ChannelExpr::Unitary(ur), + Op::Gate(gate) => ChannelExpr::Gate(gate), Op::Channel(ch) => ch, } } @@ -280,44 +369,105 @@ impl Op { } /// Promotes this `Op` to at least the Unitary level. - /// Returns `None` if at Channel level (cannot demote). + /// Returns `None` if at Gate or Channel level (cannot demote). #[must_use] pub fn to_unitary_level(self) -> Option { match self { Op::Pauli(ps) => Some(Op::Unitary(UnitaryRep::from(ps))), Op::Clifford(_, ur) | Op::Unitary(ur) => Some(Op::Unitary(ur)), - Op::Channel(_) => None, + Op::Gate(_) | Op::Channel(_) => None, } } + /// Promotes this `Op` to the Gate level. + #[must_use] + pub fn to_gate_level(self) -> Option { + self.into_gate().map(Op::Gate) + } + /// Promotes this `Op` to the Channel level. #[must_use] pub fn to_channel_level(self) -> Op { Op::Channel(self.into_channel()) } + /// Returns the tensor product of two operations. + /// + /// This is the fallible form of the `&` operator. Tensor products require + /// disjoint qubit support; use `*` for sequential composition on the same + /// qubits. + /// + /// # Errors + /// + /// Returns [`TensorProductError`] when the two operations touch any of the + /// same qubits. + pub fn try_tensor(self, rhs: Op) -> Result { + require_disjoint_support(&self.qubits(), &rhs.qubits())?; + Ok(self.tensor_unchecked(rhs)) + } + + fn tensor_unchecked(self, rhs: Op) -> Op { + let max_level = self.level().max(rhs.level()); + match max_level { + Level::Pauli => { + let a = self.into_pauli().expect("max_level is Pauli"); + let b = rhs.into_pauli().expect("max_level is Pauli"); + Op::Pauli(&a & &b) + } + Level::Clifford => { + let (cr_a, ur_a) = match self { + Op::Pauli(ps) => pauli_to_cliff_pair(ps), + Op::Clifford(cr, ur) => (cr, ur), + _ => unreachable!(), + }; + let (cr_b, ur_b) = match rhs { + Op::Pauli(ps) => pauli_to_cliff_pair(ps), + Op::Clifford(cr, ur) => (cr, ur), + _ => unreachable!(), + }; + cliff(cr_a.compose(&cr_b), ur_a & ur_b) + } + Level::Unitary => { + let a = self.into_unitary().expect("max_level is Unitary"); + let b = rhs.into_unitary().expect("max_level is Unitary"); + Op::Unitary(a & b) + } + Level::Gate => { + let a = self.into_gate().expect("max_level is Gate"); + let b = rhs.into_gate().expect("max_level is Gate"); + Op::Gate(GateExpr::Tensor(vec![a, b])) + } + Level::Channel => { + let a = self.into_channel(); + let b = rhs.into_channel(); + Op::Channel(ChannelExpr::Tensor(vec![a, b])) + } + } + } + /// Returns the adjoint (dagger) of this expression. /// /// # Panics - /// Panics if called on a Channel-level `Op` (channels are not invertible). + /// Panics if called on a Gate-level or Channel-level `Op` (not generally invertible). #[must_use] pub fn dg(&self) -> Op { match self { Op::Pauli(ps) => Op::Pauli(ps.clone()), Op::Clifford(cr, ur) => cliff(cr.inverse(), ur.dg()), Op::Unitary(ur) => Op::Unitary(ur.dg()), + Op::Gate(_) => panic!("dg() is not defined for Gate-level operations"), Op::Channel(_) => panic!("dg() is not defined for Channel-level operations"), } } - /// Returns the adjoint if this is a unitary-level operation, `None` for channels. + /// Returns the adjoint if this is a unitary-level operation, `None` for gates/channels. #[must_use] pub fn try_dg(&self) -> Option { match self { Op::Pauli(ps) => Some(Op::Pauli(ps.clone())), Op::Clifford(cr, ur) => Some(cliff(cr.inverse(), ur.dg())), Op::Unitary(ur) => Some(Op::Unitary(ur.dg())), - Op::Channel(_) => None, + Op::Gate(_) | Op::Channel(_) => None, } } @@ -326,8 +476,15 @@ impl Op { pub fn qubits(&self) -> Vec { match self { Op::Pauli(ps) => ps.qubits(), - Op::Clifford(cr, _) => (0..cr.num_qubits()).collect(), + Op::Clifford(cr, ur) => { + let mut qs = cr.support_qubits(); + qs.extend(ur.qubits()); + qs.sort_unstable(); + qs.dedup(); + qs + } Op::Unitary(ur) => ur.qubits(), + Op::Gate(gate) => gate.qubits(), Op::Channel(ch) => ch.qubits(), } } @@ -339,6 +496,71 @@ impl Op { } } +// --- GateExpr methods --- + +impl GateExpr { + /// Returns the set of qubit indices this gate expression acts on. + #[must_use] + pub fn qubits(&self) -> Vec { + let mut qs = Vec::new(); + self.collect_qubits(&mut qs); + qs.sort_unstable(); + qs.dedup(); + qs + } + + fn collect_qubits(&self, out: &mut Vec) { + match self { + GateExpr::Prep { qubit, .. } + | GateExpr::Measure { qubit, .. } + | GateExpr::Reset { qubit, .. } => { + out.push(*qubit); + } + GateExpr::Unitary(ur) => { + out.extend(ur.qubits()); + } + GateExpr::Tensor(parts) | GateExpr::Compose(parts) => { + for part in parts { + part.collect_qubits(out); + } + } + } + } +} + +impl fmt::Display for GateExpr { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + GateExpr::Unitary(ur) => write!(f, "{ur:?}"), + GateExpr::Prep { basis, qubit } => write!(f, "P{basis:?}({qubit})"), + GateExpr::Measure { basis, qubit } => write!(f, "M{basis:?}({qubit})"), + GateExpr::Reset { + basis: Basis::Z, + qubit, + } => write!(f, "Reset({qubit})"), + GateExpr::Reset { basis, qubit } => write!(f, "Reset{basis:?}({qubit})"), + GateExpr::Tensor(parts) => { + for (i, part) in parts.iter().enumerate() { + if i > 0 { + write!(f, " & ")?; + } + write!(f, "{part}")?; + } + Ok(()) + } + GateExpr::Compose(parts) => { + for (i, part) in parts.iter().enumerate() { + if i > 0 { + write!(f, " * ")?; + } + write!(f, "{part}")?; + } + Ok(()) + } + } + } +} + // --- ChannelExpr methods --- impl ChannelExpr { @@ -354,18 +576,18 @@ impl ChannelExpr { fn collect_qubits(&self, out: &mut Vec) { match self { - ChannelExpr::Prep { qubit, .. } - | ChannelExpr::Measure { qubit, .. } - | ChannelExpr::AmplitudeDamping { qubit, .. } + ChannelExpr::AmplitudeDamping { qubit, .. } | ChannelExpr::PhaseDamping { qubit, .. } | ChannelExpr::Erasure { qubit, .. } - | ChannelExpr::Reset { qubit } | ChannelExpr::Leakage { qubit, .. } => { out.push(*qubit); } ChannelExpr::Unitary(ur) => { out.extend(ur.qubits()); } + ChannelExpr::Gate(gate) => { + out.extend(gate.qubits()); + } ChannelExpr::MixedUnitary(ops) => { for (_, ur) in ops { out.extend(ur.qubits()); @@ -383,9 +605,8 @@ impl ChannelExpr { impl fmt::Display for ChannelExpr { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { - ChannelExpr::Prep { basis, qubit } => write!(f, "P{basis:?}({qubit})"), - ChannelExpr::Measure { basis, qubit } => write!(f, "M{basis:?}({qubit})"), ChannelExpr::Unitary(ur) => write!(f, "{ur:?}"), + ChannelExpr::Gate(gate) => write!(f, "{gate}"), ChannelExpr::MixedUnitary(ops) => { write!(f, "MixedUnitary[")?; for (i, (p, ur)) in ops.iter().enumerate() { @@ -405,7 +626,6 @@ impl fmt::Display for ChannelExpr { ChannelExpr::Erasure { prob, qubit } => { write!(f, "Erasure({prob}, {qubit})") } - ChannelExpr::Reset { qubit } => write!(f, "Reset({qubit})"), ChannelExpr::Leakage { rate, qubit } => { write!(f, "Leakage({rate}, {qubit})") } @@ -445,37 +665,7 @@ impl BitAnd for Op { type Output = Op; fn bitand(self, rhs: Op) -> Op { - let max_level = self.level().max(rhs.level()); - match max_level { - Level::Pauli => { - let a = self.into_pauli().expect("max_level is Pauli"); - let b = rhs.into_pauli().expect("max_level is Pauli"); - Op::Pauli(&a & &b) - } - Level::Clifford => { - let (cr_a, ur_a) = match self { - Op::Pauli(ps) => pauli_to_cliff_pair(ps), - Op::Clifford(cr, ur) => (cr, ur), - _ => unreachable!(), - }; - let (cr_b, ur_b) = match rhs { - Op::Pauli(ps) => pauli_to_cliff_pair(ps), - Op::Clifford(cr, ur) => (cr, ur), - _ => unreachable!(), - }; - cliff(cr_a.compose(&cr_b), ur_a & ur_b) - } - Level::Unitary => { - let a = self.into_unitary().expect("max_level is Unitary"); - let b = rhs.into_unitary().expect("max_level is Unitary"); - Op::Unitary(a & b) - } - Level::Channel => { - let a = self.into_channel(); - let b = rhs.into_channel(); - Op::Channel(ChannelExpr::Tensor(vec![a, b])) - } - } + self.try_tensor(rhs).unwrap_or_else(|err| panic!("{err}")) } } @@ -510,6 +700,11 @@ impl Mul for Op { let b = rhs.into_unitary().expect("max_level is Unitary"); Op::Unitary(a * b) } + Level::Gate => { + let a = self.into_gate().expect("max_level is Gate"); + let b = rhs.into_gate().expect("max_level is Gate"); + Op::Gate(GateExpr::Compose(vec![a, b])) + } Level::Channel => { let a = self.into_channel(); let b = rhs.into_channel(); @@ -573,6 +768,7 @@ impl Neg for Op { Op::Pauli(ps) => Op::Pauli(-ps), Op::Clifford(cr, ur) => cliff(cr, -ur), Op::Unitary(ur) => Op::Unitary(-ur), + Op::Gate(_) => panic!("negation is not defined for Gate-level operations"), Op::Channel(_) => panic!("negation is not defined for Channel-level operations"), } } @@ -594,6 +790,9 @@ impl Mul for ImaginaryUnit { Op::Pauli(ps) => Op::Pauli(self * ps), Op::Clifford(cr, ur) => cliff(cr, self * ur), Op::Unitary(ur) => Op::Unitary(self * ur), + Op::Gate(_) => { + panic!("phase multiplication is not defined for Gate-level operations") + } Op::Channel(_) => { panic!("phase multiplication is not defined for Channel-level operations") } @@ -617,6 +816,9 @@ impl Mul for NegImaginaryUnit { Op::Pauli(ps) => Op::Pauli(self * ps), Op::Clifford(cr, ur) => cliff(cr, self * ur), Op::Unitary(ur) => Op::Unitary(self * ur), + Op::Gate(_) => { + panic!("phase multiplication is not defined for Gate-level operations") + } Op::Channel(_) => { panic!("phase multiplication is not defined for Channel-level operations") } @@ -637,19 +839,22 @@ impl Mul<&Op> for NegImaginaryUnit { /// Applies the global phase e^{i*angle} to the operation. /// /// # Panics -/// Panics if applied to a Channel-level operation. +/// Panics if applied to a Gate-level or Channel-level operation. impl Mul for PhaseValue { type Output = Op; fn mul(self, rhs: Op) -> Op { match rhs { + Op::Gate(_) => { + panic!("phase multiplication is not defined for Gate-level operations") + } Op::Channel(_) => { panic!("phase multiplication is not defined for Channel-level operations") } other => { let ur = other .into_unitary() - .expect("non-Channel Op is convertible to Unitary"); + .expect("non-Gate/non-Channel Op is convertible to Unitary"); Op::Unitary(self * ur) } } @@ -702,6 +907,18 @@ impl From for Op { } } +impl From for Op { + fn from(gate: GateExpr) -> Op { + Op::Gate(gate) + } +} + +impl From for Op { + fn from(channel: ChannelExpr) -> Op { + Op::Channel(channel) + } +} + // --- Display --- impl fmt::Display for Op { @@ -710,6 +927,7 @@ impl fmt::Display for Op { Op::Pauli(ps) => write!(f, "{ps}"), Op::Clifford(cr, _) => write!(f, "{cr}"), Op::Unitary(ur) => write!(f, "{ur:?}"), + Op::Gate(gate) => write!(f, "{gate}"), Op::Channel(ch) => write!(f, "{ch}"), } } @@ -1179,13 +1397,13 @@ pub fn CCX(c0: impl Into, c1: impl Into, target: impl Into state (Z-basis preparation). #[allow(non_snake_case)] #[must_use] pub fn PZ(qubit: impl Into) -> Op { - Op::Channel(ChannelExpr::Prep { + Op::Gate(GateExpr::Prep { basis: Basis::Z, qubit: qubit.into().0, }) @@ -1195,17 +1413,27 @@ pub fn PZ(qubit: impl Into) -> Op { #[allow(non_snake_case)] #[must_use] pub fn PX(qubit: impl Into) -> Op { - Op::Channel(ChannelExpr::Prep { + Op::Gate(GateExpr::Prep { basis: Basis::X, qubit: qubit.into().0, }) } +/// Prepare qubit in the Y-basis +1 eigenstate. +#[allow(non_snake_case)] +#[must_use] +pub fn PY(qubit: impl Into) -> Op { + Op::Gate(GateExpr::Prep { + basis: Basis::Y, + qubit: qubit.into().0, + }) +} + /// Measure qubit in the Z basis (computational basis measurement). #[allow(non_snake_case)] #[must_use] pub fn MZ(qubit: impl Into) -> Op { - Op::Channel(ChannelExpr::Measure { + Op::Gate(GateExpr::Measure { basis: Basis::Z, qubit: qubit.into().0, }) @@ -1215,12 +1443,22 @@ pub fn MZ(qubit: impl Into) -> Op { #[allow(non_snake_case)] #[must_use] pub fn MX(qubit: impl Into) -> Op { - Op::Channel(ChannelExpr::Measure { + Op::Gate(GateExpr::Measure { basis: Basis::X, qubit: qubit.into().0, }) } +/// Measure qubit in the Y basis. +#[allow(non_snake_case)] +#[must_use] +pub fn MY(qubit: impl Into) -> Op { + Op::Gate(GateExpr::Measure { + basis: Basis::Y, + qubit: qubit.into().0, + }) +} + // --- Noise channel constructors --- /// Single-qubit depolarizing channel: ρ → (1−p)ρ + (p/3)(XρX + `YρY` + `ZρZ`). @@ -1341,6 +1579,7 @@ pub fn Depolarizing2(p: f64, q0: impl Into, q1: impl Into) -> assert!((0.0..=1.0).contains(&p), "probability p must be in [0, 1]"); let a = q0.into(); let b = q1.into(); + assert_distinct_qubits("Depolarizing2", [a.0, b.0]); let p15 = p / 15.0; let paulis_1q = [ unitary_rep::I, @@ -1405,7 +1644,8 @@ pub fn Erasure(prob: f64, qubit: impl Into) -> Op { #[allow(non_snake_case)] #[must_use] pub fn Reset(qubit: impl Into) -> Op { - Op::Channel(ChannelExpr::Reset { + Op::Gate(GateExpr::Reset { + basis: Basis::Z, qubit: qubit.into().0, }) } @@ -1521,6 +1761,18 @@ mod tests { assert!(op.is_clifford()); } + #[test] + fn clifford_tensor_uses_actual_support_not_span() { + let op = H(0) & SZ(3); + let cr = op.as_clifford().unwrap(); + + assert_eq!(op.qubits(), vec![0, 3]); + assert_eq!(cr.apply(&PauliString::x(0)), PauliString::z(0)); + assert_eq!(cr.apply(&PauliString::z(0)), PauliString::x(0)); + assert_eq!(cr.apply(&PauliString::x(3)), PauliString::y(3)); + assert_eq!(cr.apply(&PauliString::z(3)), PauliString::z(3)); + } + #[test] fn pauli_unitary_tensor_promotes() { let op = X(0) & T(3); @@ -1539,6 +1791,131 @@ mod tests { assert!(op.is_unitary()); } + #[test] + fn try_tensor_reports_overlapping_qubits() { + let err = X(0).try_tensor(Z(0)).unwrap_err(); + assert_eq!(err.overlapping_qubits(), &[0]); + } + + #[test] + fn try_tensor_rejects_mixed_level_overlaps() { + let cases = [ + ("pauli-clifford", X(0), H(0), vec![0]), + ("pauli-unitary", X(0), T(0), vec![0]), + ("pauli-gate", X(0), MZ(0), vec![0]), + ("pauli-channel", X(0), Depolarizing(0.01, 0), vec![0]), + ("clifford-gate", H(0), MZ(0), vec![0]), + ("gate-channel", MZ(0), Depolarizing(0.01, 0), vec![0]), + ("partial-multi-qubit", CX(0, 2), H(2), vec![2]), + ]; + + for (name, lhs, rhs, expected_overlap) in cases { + let err = lhs.try_tensor(rhs).unwrap_err(); + assert_eq!( + err.overlapping_qubits(), + expected_overlap.as_slice(), + "{name}" + ); + } + } + + #[test] + fn tensor_operator_panics_are_consistent_across_levels() { + fn assert_overlap_panic(f: impl FnOnce() + std::panic::UnwindSafe, expected: &str) { + let err = std::panic::catch_unwind(f).expect_err("expected tensor overlap panic"); + let message = if let Some(message) = err.downcast_ref::() { + message.as_str() + } else if let Some(message) = err.downcast_ref::<&str>() { + message + } else { + panic!("unexpected non-string panic payload"); + }; + assert!( + message.contains("tensor product requires disjoint operator support"), + "{message}" + ); + assert!(message.contains(expected), "{message}"); + } + + assert_overlap_panic( + || { + let _ = X(0) & Z(0); + }, + "[0]", + ); + assert_overlap_panic( + || { + let _ = H(0) & T(0); + }, + "[0]", + ); + assert_overlap_panic( + || { + let _ = T(0) & MZ(0); + }, + "[0]", + ); + assert_overlap_panic( + || { + let _ = MZ(0) & Depolarizing(0.01, 0); + }, + "[0]", + ); + assert_overlap_panic( + || { + let _ = CX(0, 2) & Depolarizing(0.01, 2); + }, + "[2]", + ); + } + + #[test] + fn try_tensor_accepts_mixed_level_disjoint_support() { + assert!((X(0).try_tensor(H(1))).unwrap().is_clifford()); + assert!((H(0).try_tensor(T(1))).unwrap().is_unitary()); + assert!( + (MZ(0).try_tensor(Depolarizing(0.01, 1))) + .unwrap() + .is_channel() + ); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint operator support")] + fn pauli_tensor_rejects_overlapping_qubits() { + let _ = X(0) & Z(0); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint operator support")] + fn clifford_tensor_rejects_overlapping_qubits() { + let _ = H(0) & SZ(0); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint operator support")] + fn gate_tensor_rejects_overlapping_qubits() { + let _ = H(0) & MZ(0); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint operator support")] + fn channel_tensor_rejects_overlapping_qubits() { + let _ = H(0) & Depolarizing(0.01, 0); + } + + #[test] + #[should_panic(expected = "SWAP requires distinct qubits")] + fn op_two_qubit_gate_rejects_repeated_qubit() { + let _ = SWAP(0, 0); + } + + #[test] + #[should_panic(expected = "Depolarizing2 requires distinct qubits")] + fn op_two_qubit_channel_rejects_repeated_qubit() { + let _ = Depolarizing2(0.01, 2, 2); + } + // --- Composition promotion --- #[test] @@ -1589,8 +1966,9 @@ mod tests { } #[test] - fn into_unitary_none_for_channel() { + fn into_unitary_none_for_gate_and_channel() { assert!(MZ(0).into_unitary().is_none()); + assert!(Depolarizing(0.01, 0).into_unitary().is_none()); } // --- Level promotion --- @@ -1613,8 +1991,9 @@ mod tests { } #[test] - fn to_unitary_level_none_for_channel() { + fn to_unitary_level_none_for_gate_and_channel() { assert!(MZ(0).to_unitary_level().is_none()); + assert!(Depolarizing(0.01, 0).to_unitary_level().is_none()); } // --- Adjoint --- @@ -1763,6 +2142,8 @@ mod tests { fn level_ordering() { assert!(Level::Pauli < Level::Clifford); assert!(Level::Clifford < Level::Unitary); + assert!(Level::Unitary < Level::Gate); + assert!(Level::Gate < Level::Channel); } // --- From conversions --- @@ -1900,44 +2281,60 @@ mod tests { assert!(b.is_clifford()); } - // --- Channel level --- + // --- Gate level --- #[test] - fn channel_level() { - assert!(MZ(0).is_channel()); - assert!(MX(0).is_channel()); - assert!(PZ(0).is_channel()); - assert!(PX(0).is_channel()); + fn gate_level() { + assert!(MZ(0).is_gate()); + assert!(MX(0).is_gate()); + assert!(MY(0).is_gate()); + assert!(PZ(0).is_gate()); + assert!(PX(0).is_gate()); + assert!(PY(0).is_gate()); } #[test] - fn channel_tensor_stays_channel() { + fn gate_tensor_stays_gate() { let op = MZ(0) & MZ(1); - assert!(op.is_channel()); + assert!(op.is_gate()); } #[test] - fn channel_compose_stays_channel() { + fn gate_compose_stays_gate() { let op = PZ(0) * MZ(0); - assert!(op.is_channel()); + assert!(op.is_gate()); } #[test] - fn unitary_channel_tensor_promotes() { + fn unitary_gate_tensor_promotes() { let op = H(0) & MZ(1); - assert!(op.is_channel()); + assert!(op.is_gate()); } #[test] - fn pauli_channel_tensor_promotes() { + fn non_clifford_unitary_gate_tensor_promotes_to_gate_tensor() { + let op = T(0) & MZ(1); + assert!(op.is_gate()); + assert!(matches!(op, Op::Gate(GateExpr::Tensor(parts)) if parts.len() == 2)); + } + + #[test] + fn pauli_gate_tensor_promotes() { let op = X(0) & MZ(1); - assert!(op.is_channel()); + assert!(op.is_gate()); } #[test] - fn unitary_channel_compose_promotes() { + fn unitary_gate_compose_promotes() { let op = H(0) * MZ(0); - assert!(op.is_channel()); + assert!(op.is_gate()); + } + + #[test] + fn non_clifford_unitary_gate_compose_promotes_to_gate_compose() { + let op = T(0) * MZ(0); + assert!(op.is_gate()); + assert!(matches!(op, Op::Gate(GateExpr::Compose(parts)) if parts.len() == 2)); } #[test] @@ -1950,12 +2347,21 @@ mod tests { } #[test] - fn into_clifford_none_for_channel() { + fn into_gate_promotes_unitaries_and_keeps_gates() { + assert!(X(0).into_gate().is_some()); + assert!(H(0).into_gate().is_some()); + assert!(T(0).into_gate().is_some()); + assert!(MZ(0).into_gate().is_some()); + assert!(Depolarizing(0.01, 0).into_gate().is_none()); + } + + #[test] + fn into_clifford_none_for_gate() { assert!(MZ(0).into_clifford().is_none()); } #[test] - fn try_dg_none_for_channel() { + fn try_dg_none_for_gate() { assert!(MZ(0).try_dg().is_none()); } @@ -1967,26 +2373,32 @@ mod tests { } #[test] - #[should_panic(expected = "not defined for Channel")] - fn dg_panics_for_channel() { + #[should_panic(expected = "not defined for Gate")] + fn dg_panics_for_gate() { let _ = MZ(0).dg(); } #[test] - fn channel_qubits() { + fn gate_qubits() { let op = MZ(3); assert_eq!(op.qubits(), vec![3]); assert_eq!(op.num_qubits(), 4); } #[test] - fn channel_tensor_qubits() { + fn gate_tensor_qubits() { let op = PZ(0) & MZ(2); let mut qs = op.qubits(); qs.sort_unstable(); assert_eq!(qs, vec![0, 2]); } + #[test] + fn gate_channel_tensor_promotes_to_channel() { + let op = MZ(0) & Depolarizing(0.01, 1); + assert!(op.is_channel()); + } + #[test] fn to_channel_level_promotes() { assert!(X(0).to_channel_level().is_channel()); @@ -1995,12 +2407,6 @@ mod tests { assert!(MZ(0).to_channel_level().is_channel()); } - #[test] - fn level_ordering_with_channel() { - assert!(Level::Unitary < Level::Channel); - assert!(Level::Pauli < Level::Channel); - } - // --- Noise channels --- #[test] @@ -2091,12 +2497,26 @@ mod tests { assert!(op.is_channel()); } + #[test] + fn non_clifford_unitary_channel_tensor_promotes_to_channel_tensor() { + let op = T(0) & Depolarizing(0.1, 1); + assert!(op.is_channel()); + assert!(matches!(op, Op::Channel(ChannelExpr::Tensor(parts)) if parts.len() == 2)); + } + #[test] fn noise_compose_with_gate() { let op = H(0) * Dephasing(0.05, 0); assert!(op.is_channel()); } + #[test] + fn non_clifford_unitary_channel_compose_promotes_to_channel_compose() { + let op = T(0) * Dephasing(0.05, 0); + assert!(op.is_channel()); + assert!(matches!(op, Op::Channel(ChannelExpr::Compose(parts)) if parts.len() == 2)); + } + #[test] fn mixed_unitary_qubits() { let op = Depolarizing(0.1, 5); @@ -2170,10 +2590,32 @@ mod tests { } #[test] - fn reset_is_channel() { + fn reset_is_gate() { let op = Reset(0); - assert!(op.is_channel()); + assert!(op.is_gate()); assert_eq!(op.qubits(), vec![0]); + + assert!(matches!( + PX(1), + Op::Gate(GateExpr::Prep { + basis: Basis::X, + qubit: 1 + }) + )); + assert!(matches!( + PY(2), + Op::Gate(GateExpr::Prep { + basis: Basis::Y, + qubit: 2 + }) + )); + assert!(matches!( + PZ(3), + Op::Gate(GateExpr::Prep { + basis: Basis::Z, + qubit: 3 + }) + )); } #[test] @@ -2195,7 +2637,6 @@ mod tests { let ops = vec![ PhaseDamping(0.1, 0), Erasure(0.05, 0), - Reset(0), Leakage(0.01, 0), AmplitudeDamping(0.1, 0), ]; @@ -2333,25 +2774,25 @@ mod tests { #[test] #[should_panic(expected = "not defined for Channel")] fn i_times_channel_panics() { - let _ = i * MZ(0); + let _ = i * Depolarizing(0.01, 0); } #[test] #[should_panic(expected = "not defined for Channel")] fn neg_channel_panics() { - let _ = -MZ(0); + let _ = -Depolarizing(0.01, 0); } #[test] #[should_panic(expected = "not defined for Channel")] fn generic_phase_channel_panics() { - let _ = phase(Angle64::QUARTER_TURN) * MZ(0); + let _ = phase(Angle64::QUARTER_TURN) * Depolarizing(0.01, 0); } #[test] #[should_panic(expected = "negation is not defined for Channel")] fn minus_one_channel_panics() { - let _ = -1 * MZ(0); + let _ = -1 * Depolarizing(0.01, 0); } // --- Noise boundary values --- diff --git a/crates/pecos-core/src/operator.rs b/crates/pecos-core/src/operator.rs deleted file mode 100644 index aae35de9d..000000000 --- a/crates/pecos-core/src/operator.rs +++ /dev/null @@ -1,4037 +0,0 @@ -// Copyright 2024 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. - -//! Gate expression algebra for quantum circuits. -//! -//! This module provides a lazy expression tree for building and manipulating -//! quantum gate sequences with algebraic simplification. -//! -//! # Representation Hierarchy -//! -//! 1. **Rotation gates**: `exp(-i θ/2 P)` where P is a Pauli - covers most gates -//! 2. **Control gates**: CX, CZ, SWAP, etc. - stay as named gates -//! -//! # Operators -//! -//! - `&` - Tensor product (operators on different qubits) -//! - `*` - Composition (matrix multiplication order: A * B means apply B then A) -//! - `.dg()` - Adjoint (Hermitian conjugate) -//! -//! # Examples -//! -//! ``` -//! use pecos_core::operator::*; -//! use pecos_core::Angle64; -//! -//! // Build a circuit: H on q0, then CX(0,1), then T on q1 -//! let circuit = T(1) * CX(0, 1) * H(0); -//! -//! // Check if it's Clifford -//! assert!(!circuit.is_clifford()); // T is not Clifford -//! -//! // Clifford circuit -//! let cliff = CX(0, 1) * H(0); -//! assert!(cliff.is_clifford()); -//! -//! // Tensor product -//! let two_qubit = X(0) & Z(1); -//! -//! // Adjoint -//! let inv = circuit.dg(); -//! ``` - -use crate::gate_type::GateType; -use crate::pauli::PauliOperator; -use crate::phase::Phase; -use crate::{Angle64, PauliString, QuarterPhase, QubitId}; -use smallvec::SmallVec; -use std::ops::{BitAnd, Mul, Neg}; - -// --- Phase macros for exact arithmetic --- - -/// Creates a `PhaseValue` from a pi-based expression for use with operators. -/// -/// This is a convenience wrapper around `angle!` that returns a `PhaseValue` -/// which can be directly multiplied with gate expressions. -/// -/// # Examples -/// -/// ``` -/// use pecos_core::{phase, Angle64}; -/// use pecos_core::operator::X; -/// -/// // e^{iπ/4} * X - exact, no floating point -/// let op = phase!(pi / 4) * X(0); -/// -/// // e^{iπ/2} * X = i * X -/// let op = phase!(pi / 2) * X(0); -/// -/// // e^{i * 2π/3} * X -/// let op = phase!(2 * pi / 3) * X(0); -/// ``` -#[macro_export] -macro_rules! phase { - ($($tokens:tt)*) => { - $crate::operator::PhaseValue($crate::angle!($($tokens)*)) - }; -} - -/// Creates a `PhaseValue` from a turn-based fraction for use with operators. -/// -/// This is a convenience wrapper around `turn!` that returns a `PhaseValue` -/// which can be directly multiplied with gate expressions. -/// -/// # Examples -/// -/// ``` -/// use pecos_core::phase_turn; -/// use pecos_core::operator::X; -/// -/// // T gate phase: e^{i * 2π/8} = e^{iπ/4} -/// let op = phase_turn!(1 / 8) * X(0); -/// -/// // SZ gate phase: e^{i * 2π/4} = e^{iπ/2} = i -/// let op = phase_turn!(1 / 4) * X(0); -/// -/// // Third of a turn: e^{i * 2π/3} -/// let op = phase_turn!(1 / 3) * X(0); -/// ``` -#[macro_export] -macro_rules! phase_turn { - ($($tokens:tt)*) => { - $crate::operator::PhaseValue($crate::turn!($($tokens)*)) - }; -} - -/// Rotation gate types - gates parameterized by an angle. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum RotationType { - /// Rotation around X axis: exp(-i θ/2 X) - RX, - /// Rotation around Y axis: exp(-i θ/2 Y) - RY, - /// Rotation around Z axis: exp(-i θ/2 Z) - RZ, - /// Two-qubit XX rotation: exp(-i θ/2 X⊗X) - RXX, - /// Two-qubit YY rotation: exp(-i θ/2 Y⊗Y) - RYY, - /// Two-qubit ZZ rotation: exp(-i θ/2 Z⊗Z) - RZZ, -} - -impl RotationType { - /// Returns the number of qubits this rotation acts on. - #[must_use] - pub fn num_qubits(&self) -> usize { - match self { - Self::RX | Self::RY | Self::RZ => 1, - Self::RXX | Self::RYY | Self::RZZ => 2, - } - } - - /// Returns the corresponding `GateType` for this rotation. - #[must_use] - pub fn to_gate_type(&self) -> GateType { - match self { - Self::RX => GateType::RX, - Self::RY => GateType::RY, - Self::RZ => GateType::RZ, - Self::RXX => GateType::RXX, - Self::RYY => GateType::RYY, - Self::RZZ => GateType::RZZ, - } - } -} - -// --- Commutativity --- - -/// Result of checking whether two operators commute. -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] -pub enum Commutativity { - /// Operators commute: AB = BA - Commutes, - /// Operators anti-commute: AB = -BA - AntiCommutes, - /// Commutativity cannot be determined (non-Pauli operators) - Unknown, -} - -// --- Qubit target types for polymorphic gate constructors --- - -/// Wrapper for qubit targets that can be a single qubit or multiple qubits. -/// -/// Enables pluralized gate functions to accept various qubit collections: -/// ``` -/// use pecos_core::operator::*; -/// use pecos_core::QubitId; -/// -/// // Multiple qubits via Xs - equivalent to X(0) & X(2) & X(5) -/// let x_multi = Xs([0, 2, 5]); -/// -/// // Also works with QubitId arrays -/// let x_multi = Xs([QubitId(0), QubitId(2)]); -/// ``` -#[derive(Debug, Clone)] -pub struct Qubits(SmallVec<[QubitId; 4]>); - -impl From for Qubits { - fn from(q: usize) -> Self { - Qubits(smallvec::smallvec![QubitId(q)]) - } -} - -impl From for Qubits { - fn from(q: QubitId) -> Self { - Qubits(smallvec::smallvec![q]) - } -} - -impl From<[usize; N]> for Qubits { - fn from(qs: [usize; N]) -> Self { - Qubits(qs.into_iter().map(QubitId).collect()) - } -} - -impl From<[QubitId; N]> for Qubits { - fn from(qs: [QubitId; N]) -> Self { - Qubits(qs.into_iter().collect()) - } -} - -impl From<&[usize]> for Qubits { - fn from(qs: &[usize]) -> Self { - Qubits(qs.iter().copied().map(QubitId).collect()) - } -} - -impl From<&[QubitId]> for Qubits { - fn from(qs: &[QubitId]) -> Self { - Qubits(qs.iter().copied().collect()) - } -} - -impl From> for Qubits { - fn from(qs: Vec) -> Self { - Qubits(qs.into_iter().map(QubitId).collect()) - } -} - -impl From> for Qubits { - fn from(qs: Vec) -> Self { - Qubits(qs.into_iter().collect()) - } -} - -impl From> for Qubits { - fn from(range: std::ops::Range) -> Self { - assert!(!range.is_empty(), "empty range not allowed for Qubits"); - Qubits(range.map(QubitId).collect()) - } -} - -impl From> for Qubits { - fn from(range: std::ops::RangeInclusive) -> Self { - assert!(!range.is_empty(), "empty range not allowed for Qubits"); - Qubits(range.map(QubitId).collect()) - } -} - -impl Qubits { - /// Returns true if there are no qubits. - #[must_use] - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - - /// Returns the number of qubits. - #[must_use] - pub fn len(&self) -> usize { - self.0.len() - } - - /// Returns the qubits as a slice. - #[must_use] - pub fn as_slice(&self) -> &[QubitId] { - &self.0 - } - - /// Applies a gate function to each qubit and returns the result. - /// For a single qubit, returns the gate directly. - /// For multiple qubits, returns a Tensor of the gates. - #[must_use] - pub fn apply(self, gate_fn: F) -> Operator - where - F: Fn(usize) -> Operator, - { - match self.0.len() { - 0 => Operator::Pauli(PauliString::default()), // Identity - 1 => gate_fn(self.0[0].0), - _ => Operator::Tensor(self.0.iter().map(|q| gate_fn(q.0)).collect()), - } - } -} - -/// Wrapper for qubit pairs used by pluralized two-qubit gates. -/// -/// ``` -/// use pecos_core::operator::*; -/// use pecos_core::QubitId; -/// -/// // Multiple CX gates via CXs -/// let cx_multi = CXs([(0, 1), (2, 3)]); -/// -/// // Also works with QubitId pairs -/// let cx_multi = CXs([(QubitId(0), QubitId(1))]); -/// ``` -#[derive(Debug, Clone)] -pub struct QubitPairs(SmallVec<[(QubitId, QubitId); 2]>); - -impl From<(usize, usize)> for QubitPairs { - fn from(pair: (usize, usize)) -> Self { - QubitPairs(smallvec::smallvec![(QubitId(pair.0), QubitId(pair.1))]) - } -} - -impl From<(QubitId, QubitId)> for QubitPairs { - fn from(pair: (QubitId, QubitId)) -> Self { - QubitPairs(smallvec::smallvec![pair]) - } -} - -impl From<[(usize, usize); N]> for QubitPairs { - fn from(pairs: [(usize, usize); N]) -> Self { - QubitPairs( - pairs - .into_iter() - .map(|(a, b)| (QubitId(a), QubitId(b))) - .collect(), - ) - } -} - -impl From<[(QubitId, QubitId); N]> for QubitPairs { - fn from(pairs: [(QubitId, QubitId); N]) -> Self { - QubitPairs(pairs.into_iter().collect()) - } -} - -impl From<&[(usize, usize)]> for QubitPairs { - fn from(pairs: &[(usize, usize)]) -> Self { - QubitPairs( - pairs - .iter() - .map(|&(a, b)| (QubitId(a), QubitId(b))) - .collect(), - ) - } -} - -impl From<&[(QubitId, QubitId)]> for QubitPairs { - fn from(pairs: &[(QubitId, QubitId)]) -> Self { - QubitPairs(pairs.iter().copied().collect()) - } -} - -impl From> for QubitPairs { - fn from(pairs: Vec<(usize, usize)>) -> Self { - QubitPairs( - pairs - .into_iter() - .map(|(a, b)| (QubitId(a), QubitId(b))) - .collect(), - ) - } -} - -impl From> for QubitPairs { - fn from(pairs: Vec<(QubitId, QubitId)>) -> Self { - QubitPairs(pairs.into_iter().collect()) - } -} - -impl QubitPairs { - /// Returns true if there are no pairs. - #[must_use] - pub fn is_empty(&self) -> bool { - self.0.is_empty() - } - - /// Returns the number of pairs. - #[must_use] - pub fn len(&self) -> usize { - self.0.len() - } - - /// Applies a gate function to each pair and returns the result. - /// For a single pair, returns the gate directly. - /// For multiple pairs, returns a Tensor of the gates. - #[must_use] - pub fn apply(self, gate_fn: F) -> Operator - where - F: Fn(usize, usize) -> Operator, - { - match self.0.len() { - 0 => Operator::Pauli(PauliString::default()), // Identity - 1 => gate_fn(self.0[0].0.0, self.0[0].1.0), - _ => Operator::Tensor(self.0.iter().map(|(q0, q1)| gate_fn(q0.0, q1.0)).collect()), - } - } -} - -/// A gate/operator expression - lazy representation of quantum operators. -/// -/// This is the unified type for all quantum operators including Pauli operators, -/// Clifford gates, and general unitaries. -#[derive(Debug, Clone, PartialEq)] -pub enum Operator { - /// Pauli operator (single or multi-qubit) - /// Wraps `PauliString` for exact Pauli algebra - Pauli(PauliString), - - /// Rotation gate with angle: exp(-i θ/2 P) - Rotation { - rotation_type: RotationType, - angle: Angle64, - qubits: SmallVec<[usize; 2]>, - }, - - /// Fixed gate (control gates, etc.) without angle parameter - Gate { - gate_type: GateType, - qubits: SmallVec<[usize; 3]>, - }, - - /// Tensor product of expressions (operators on different qubits) - Tensor(Vec), - - /// Sequential composition (matrix multiplication order) - /// Compose([A, B, C]) means apply A, then B, then C - Compose(Vec), - - /// Adjoint (Hermitian conjugate) - Adjoint(Box), - - /// Global phase: e^{i*phase} * inner - /// Phase is represented as Angle64 for exact arithmetic - Phase { - phase: Angle64, - inner: Box, - }, -} - -impl Operator { - /// Creates a rotation gate expression. - #[must_use] - pub fn rotation( - rotation_type: RotationType, - angle: Angle64, - qubits: impl Into>, - ) -> Self { - Self::Rotation { - rotation_type, - angle, - qubits: qubits.into(), - } - } - - /// Creates a fixed gate expression. - #[must_use] - pub fn gate(gate_type: GateType, qubits: impl Into>) -> Self { - Self::Gate { - gate_type, - qubits: qubits.into(), - } - } - - /// Returns the adjoint (Hermitian conjugate) of this expression. - #[must_use] - pub fn dg(&self) -> Self { - match self { - // Pauli adjoint: Paulis are Hermitian, but phase conjugates - Self::Pauli(ps) => { - let conj_phase = ps.phase().conjugate(); - Self::Pauli(PauliString::with_phase_and_paulis( - conj_phase, - ps.iter_pairs().collect(), - )) - } - // Rotation adjoint: negate the angle - Self::Rotation { - rotation_type, - angle, - qubits, - } => Self::Rotation { - rotation_type: *rotation_type, - angle: negate_angle(*angle), - qubits: qubits.clone(), - }, - // Gate adjoint: wrap or simplify for self-adjoint gates - Self::Gate { - gate_type, - qubits: _, - } => { - if gate_type.is_self_adjoint() { - self.clone() - } else { - Self::Adjoint(Box::new(self.clone())) - } - } - // Tensor adjoint: adjoint of each part - Self::Tensor(parts) => Self::Tensor(parts.iter().map(Operator::dg).collect()), - // Compose adjoint: reverse order and adjoint each - Self::Compose(parts) => Self::Compose(parts.iter().rev().map(Operator::dg).collect()), - // Double adjoint: unwrap - Self::Adjoint(inner) => (**inner).clone(), - // Phase adjoint: conjugate phase (negate), adjoint inner - Self::Phase { phase, inner } => Self::Phase { - phase: negate_angle(*phase), - inner: Box::new(inner.dg()), - }, - } - } - - /// Applies a global phase to this expression: e^{i*phase} * self - #[must_use] - pub fn with_phase(self, phase: Angle64) -> Self { - if phase == Angle64::ZERO { - return self; - } - - // For Pauli variants, try to absorb the phase into the PauliString - // if it's a multiple of π/2 (quarter turn) - if let Self::Pauli(ps) = self { - if let Some(quarter_phase) = angle_to_quarter_phase(phase) { - let new_phase = ps.phase().multiply(&quarter_phase); - return Self::Pauli(PauliString::with_phase_and_paulis( - new_phase, - ps.iter_pairs().collect(), - )); - } - // Not a quarter turn multiple, wrap in Phase - return Self::Phase { - phase, - inner: Box::new(Self::Pauli(ps)), - }; - } - - Self::Phase { - phase, - inner: Box::new(self), - } - } - - /// Checks if this expression represents a Clifford operation. - /// - /// Clifford gates are those where all rotation angles are multiples of π/2. - #[must_use] - pub fn is_clifford(&self) -> bool { - match self { - // Paulis are always Clifford - Self::Pauli(_) => true, - Self::Rotation { angle, .. } => { - // Clifford if angle is multiple of π/2 (quarter turn) - is_multiple_of_quarter_turn(*angle) - } - Self::Gate { gate_type, .. } => gate_type.is_clifford(), - Self::Tensor(parts) | Self::Compose(parts) => parts.iter().all(Operator::is_clifford), - // Phase doesn't affect Clifford-ness (global phase) - Self::Adjoint(inner) | Self::Phase { inner, .. } => inner.is_clifford(), - } - } - - /// Returns the qubits this expression acts on. - #[must_use] - pub fn qubits(&self) -> Vec { - let mut result = Vec::new(); - self.collect_qubits(&mut result); - result.sort_unstable(); - result.dedup(); - result - } - - fn collect_qubits(&self, result: &mut Vec) { - match self { - Self::Pauli(ps) => { - result.extend(ps.iter_pairs().map(|(_, q)| usize::from(q))); - } - Self::Rotation { qubits, .. } => result.extend(qubits.iter().copied()), - Self::Gate { qubits, .. } => result.extend(qubits.iter().copied()), - Self::Tensor(parts) | Self::Compose(parts) => { - for part in parts { - part.collect_qubits(result); - } - } - Self::Adjoint(inner) | Self::Phase { inner, .. } => inner.collect_qubits(result), - } - } -} - -// --- Negation operator: -op (phase by π) --- - -impl Neg for Operator { - type Output = Operator; - - fn neg(self) -> Operator { - self.with_phase(Angle64::HALF_TURN) - } -} - -impl Neg for &Operator { - type Output = Operator; - - fn neg(self) -> Operator { - self.clone().with_phase(Angle64::HALF_TURN) - } -} - -// --- Imaginary unit for phase multiplication --- - -/// Imaginary unit for phase multiplication: i * op -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct ImaginaryUnit; - -/// The imaginary unit `i`. -#[allow(non_upper_case_globals)] -pub const i: ImaginaryUnit = ImaginaryUnit; - -impl Neg for ImaginaryUnit { - type Output = NegImaginaryUnit; - - fn neg(self) -> NegImaginaryUnit { - NegImaginaryUnit - } -} - -/// Negative imaginary unit (-i). -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub struct NegImaginaryUnit; - -impl Mul for ImaginaryUnit { - type Output = Operator; - - fn mul(self, rhs: Operator) -> Operator { - rhs.with_phase(Angle64::QUARTER_TURN) // i = e^{iπ/2} - } -} - -impl Mul<&Operator> for ImaginaryUnit { - type Output = Operator; - - fn mul(self, rhs: &Operator) -> Operator { - rhs.clone().with_phase(Angle64::QUARTER_TURN) - } -} - -impl Mul for NegImaginaryUnit { - type Output = Operator; - - #[allow(clippy::suspicious_arithmetic_impl)] // Adding angles for phase computation - fn mul(self, rhs: Operator) -> Operator { - rhs.with_phase(Angle64::QUARTER_TURN + Angle64::HALF_TURN) // -i = e^{i3π/2} - } -} - -impl Mul<&Operator> for NegImaginaryUnit { - type Output = Operator; - - #[allow(clippy::suspicious_arithmetic_impl)] // Adding angles for phase computation - fn mul(self, rhs: &Operator) -> Operator { - rhs.clone() - .with_phase(Angle64::QUARTER_TURN + Angle64::HALF_TURN) - } -} - -// --- General phase value for arbitrary phase multiplication --- - -/// A phase value e^{i*angle} that can be multiplied with operators. -/// -/// # Example -/// ``` -/// use pecos_core::operator::{phase, X}; -/// use pecos_core::Angle64; -/// -/// // Create a phase of e^{iπ/4} -/// let eighth_turn = Angle64::HALF_TURN / 4; -/// let op = phase(eighth_turn) * X(0); // e^{iπ/4} * X -/// ``` -#[derive(Debug, Clone, Copy, PartialEq)] -pub struct PhaseValue(pub Angle64); - -/// Creates a phase value e^{i*angle} that can be multiplied with operators. -/// -/// The phase represents the complex number e^{i*angle} = cos(angle) + i*sin(angle). -/// -/// # Example -/// ``` -/// use pecos_core::operator::{phase, X, Z}; -/// use pecos_core::Angle64; -/// -/// // e^{iπ/4} * X -/// let op = phase(Angle64::HALF_TURN / 4) * X(0); -/// -/// // e^{iπ/2} * Z = i * Z -/// let op = phase(Angle64::QUARTER_TURN) * Z(0); -/// ``` -#[must_use] -pub fn phase(angle: Angle64) -> PhaseValue { - PhaseValue(angle) -} - -impl Neg for PhaseValue { - type Output = PhaseValue; - - fn neg(self) -> PhaseValue { - // -e^{iθ} = e^{i(θ + π)} - PhaseValue(self.0 + Angle64::HALF_TURN) - } -} - -impl Mul for ImaginaryUnit { - type Output = PhaseValue; - - #[allow(clippy::suspicious_arithmetic_impl)] // Adding angles for phase computation - fn mul(self, rhs: PhaseValue) -> PhaseValue { - // i * e^{iθ} = e^{i(θ + π/2)} - PhaseValue(rhs.0 + Angle64::QUARTER_TURN) - } -} - -impl Mul for NegImaginaryUnit { - type Output = PhaseValue; - - #[allow(clippy::suspicious_arithmetic_impl)] // Adding angles for phase computation - fn mul(self, rhs: PhaseValue) -> PhaseValue { - // -i * e^{iθ} = e^{i(θ + 3π/2)} - PhaseValue(rhs.0 + Angle64::QUARTER_TURN + Angle64::HALF_TURN) - } -} - -impl Mul for PhaseValue { - type Output = Operator; - - fn mul(self, rhs: Operator) -> Operator { - rhs.with_phase(self.0) - } -} - -impl Mul<&Operator> for PhaseValue { - type Output = Operator; - - fn mul(self, rhs: &Operator) -> Operator { - rhs.clone().with_phase(self.0) - } -} - -impl Operator { - /// Attempts to convert a rotation to its named `GateType` equivalent. - #[must_use] - pub fn to_named_gate(&self) -> Option { - match self { - Self::Pauli(ps) => { - // Single-qubit Paulis map to named gates - if ps.weight() == 1 && ps.phase() == QuarterPhase::PlusOne { - let (pauli, _qubit) = ps.iter_pairs().next()?; - match pauli { - crate::Pauli::I => Some(GateType::I), - crate::Pauli::X => Some(GateType::X), - crate::Pauli::Y => Some(GateType::Y), - crate::Pauli::Z => Some(GateType::Z), - } - } else { - None - } - } - Self::Rotation { - rotation_type, - angle, - .. - } => rotation_to_gate_type(*rotation_type, *angle), - Self::Gate { gate_type, .. } => Some(*gate_type), - _ => None, - } - } - - /// Returns a reference to the inner `PauliString` if this is a `Pauli` variant. - #[must_use] - pub fn as_pauli_string(&self) -> Option<&PauliString> { - if let Self::Pauli(ps) = self { - Some(ps) - } else { - None - } - } - - /// Consumes this `Operator` and returns the inner `PauliString` if this is a `Pauli` variant. - #[must_use] - pub fn into_pauli_string(self) -> Option { - if let Self::Pauli(ps) = self { - Some(ps) - } else { - None - } - } - - /// Attempts to convert this operator to a `PauliString`. - /// - /// This handles more cases than `into_pauli_string()`: - /// - `Pauli(ps)` → returns `ps` directly - /// - `Tensor([Pauli(a), Pauli(b), ...])` → merges into a single `PauliString` - /// - `Phase { phase, inner: Pauli(ps) }` → applies phase to `ps` - /// - Named Pauli gates (`X`, `Y`, `Z`) → corresponding single-qubit `PauliString` - /// - Half-turn rotations (`RX(π)`, `RY(π)`, `RZ(π)`) → corresponding `PauliString` - /// - /// Returns `None` if the operator cannot be represented as a `PauliString`. - /// - /// # Example - /// - /// ``` - /// use pecos_core::{Xs, Zs, PauliOperator}; - /// - /// // Tensor of Paulis on disjoint qubits - /// let op = Xs(0..2) & Zs(2..4); - /// let ps = op.try_to_pauli_string().unwrap(); - /// assert_eq!(ps.weight(), 4); // X on 0,1 and Z on 2,3 - /// ``` - #[must_use] - pub fn try_to_pauli_string(self) -> Option { - match self { - Self::Pauli(ps) => Some(ps), - - Self::Tensor(parts) => { - // Try to convert all parts to PauliStrings and merge - let mut result = PauliString::new(); - for part in parts { - let ps = part.try_to_pauli_string()?; - // Merge: combine the Pauli operators - // For disjoint qubits, this is just concatenation - // For overlapping qubits, we multiply the Paulis - result = result * ps; - } - Some(result) - } - - Self::Phase { phase, inner } => { - let mut ps = inner.try_to_pauli_string()?; - // Apply the global phase to the PauliString phase - // phase is Angle64, we need to convert to QuarterPhase if possible - // For now, only handle quarter-turn phases exactly - let quarter_phase = if phase == Angle64::ZERO { - QuarterPhase::PlusOne - } else if phase == Angle64::QUARTER_TURN { - QuarterPhase::PlusI - } else if phase == Angle64::HALF_TURN { - QuarterPhase::MinusOne - } else if phase == Angle64::THREE_QUARTERS_TURN { - QuarterPhase::MinusI - } else { - // Non-quarter-turn phase, can't represent exactly - return None; - }; - let new_phase = ps.phase().multiply(&quarter_phase); - ps.set_phase(new_phase); - Some(ps) - } - - Self::Gate { gate_type, qubits } => { - let qubit = qubits.first().copied()?; - match gate_type { - GateType::X => Some(PauliString::x(qubit)), - GateType::Y => Some(PauliString::y(qubit)), - GateType::Z => Some(PauliString::z(qubit)), - GateType::I => Some(PauliString::identity()), - _ => None, - } - } - - Self::Rotation { - rotation_type, - angle, - qubits, - } => { - // Only half-turn rotations are Pauli operators - let half = Angle64::HALF_TURN; - let neg_half = negate_angle(half); - if angle != half && angle != neg_half { - return None; - } - let qubit = qubits.first().copied()?; - match rotation_type { - RotationType::RX => Some(PauliString::x(qubit)), - RotationType::RY => Some(PauliString::y(qubit)), - RotationType::RZ => Some(PauliString::z(qubit)), - _ => None, - } - } - - Self::Adjoint(inner) => { - // Paulis are Hermitian (self-adjoint), but phase conjugates - let mut ps = inner.try_to_pauli_string()?; - let conj_phase = ps.phase().conjugate(); - ps.set_phase(conj_phase); - Some(ps) - } - - Self::Compose(_) => { - // Composition of Paulis requires multiplication - // This is more complex; skip for now - None - } - } - } - - /// Checks if this operator is equivalent to a Pauli operator. - /// - /// Returns true for: - /// - `Pauli` variants (any `PauliString`) - /// - Half-turn rotations: `RX(π)`, `RY(π)`, `RZ(π)` - /// - Named Pauli gates: `X`, `Y`, `Z` - #[must_use] - pub fn is_pauli_equivalent(&self) -> bool { - match self { - Self::Pauli(_) => true, - Self::Rotation { - rotation_type, - angle, - .. - } => { - let half = Angle64::HALF_TURN; - let neg_half = negate_angle(half); - (*angle == half || *angle == neg_half) - && matches!( - rotation_type, - RotationType::RX | RotationType::RY | RotationType::RZ - ) - } - Self::Gate { gate_type, .. } => { - matches!(gate_type, GateType::X | GateType::Y | GateType::Z) - } - _ => false, - } - } - - /// Attempts to convert this operator to a `Pauli` variant. - /// - /// Converts: - /// - `Pauli` → returns as-is - /// - `RX(π)` → `X` - /// - `RY(π)` → `Y` - /// - `RZ(π)` → `Z` - /// - Named gates `X`, `Y`, `Z` → corresponding `Pauli` variant - /// - /// Returns `None` if the operator is not Pauli-equivalent. - #[must_use] - pub fn try_to_pauli(self) -> Option { - match self { - Self::Pauli(_) => Some(self), - Self::Rotation { - rotation_type, - angle, - qubits, - } => { - let half = Angle64::HALF_TURN; - let neg_half = negate_angle(half); - if angle != half && angle != neg_half { - return None; - } - let qubit = qubits[0]; - match rotation_type { - RotationType::RX => Some(X(qubit)), - RotationType::RY => Some(Y(qubit)), - RotationType::RZ => Some(Z(qubit)), - _ => None, - } - } - Self::Gate { gate_type, qubits } => { - let qubit = qubits[0]; - match gate_type { - GateType::X => Some(X(qubit)), - GateType::Y => Some(Y(qubit)), - GateType::Z => Some(Z(qubit)), - _ => None, - } - } - _ => None, - } - } - - /// Simplifies this gate expression by: - /// - Merging adjacent rotations of the same type on the same qubits - /// - Canceling inverse operations (rotation + its negation) - /// - Removing identity operations (zero-angle rotations) - /// - Flattening single-element containers - #[must_use] - #[allow(clippy::missing_panics_doc)] // Internal expects are guarded by length checks - pub fn simplify(&self) -> Self { - match self { - // Pauli and Gate are already in simplified form - Self::Pauli(_) | Self::Gate { .. } => self.clone(), - - Self::Rotation { angle, .. } => { - // Remove identity rotations - if *angle == Angle64::ZERO { - return self.clone(); // Keep as-is, will be filtered at Compose level - } - self.clone() - } - - Self::Tensor(parts) => { - // Simplify each part but preserve identities (they define the Hilbert space dimension) - let simplified: Vec<_> = parts.iter().map(Operator::simplify).collect(); - - match simplified.len() { - 0 => Self::Pauli(PauliString::default()), // Empty tensor = identity - 1 => simplified.into_iter().next().expect("length is 1"), - _ => Self::Tensor(simplified), - } - } - - Self::Compose(parts) => { - // First simplify each part - let simplified: Vec<_> = parts.iter().map(Operator::simplify).collect(); - - // Flatten nested Compose nodes - let flattened = flatten_compose(simplified); - - // Merge adjacent compatible rotations - let merged = merge_adjacent_rotations(flattened); - - // Filter out identities - let filtered: Vec<_> = merged.into_iter().filter(|p| !p.is_identity()).collect(); - - match filtered.len() { - 0 => I(0), // Empty composition = identity - 1 => filtered.into_iter().next().expect("length is 1"), - _ => Self::Compose(filtered), - } - } - - Self::Adjoint(inner) => { - // Simplify inner first, then take adjoint - let simplified_inner = inner.simplify(); - simplified_inner.dg() - } - - Self::Phase { phase, inner } => { - // Simplify inner and preserve phase - let simplified_inner = inner.simplify(); - if *phase == Angle64::ZERO || *phase == Angle64::FULL_TURN { - simplified_inner - } else { - Self::Phase { - phase: *phase, - inner: Box::new(simplified_inner), - } - } - } - } - } - - /// Conjugates this operator by another: `gate * self * gate.dg()` (i.e., UAU†). - /// - /// This is the stabilizer update convention: when gate U is applied to a state, - /// a stabilizer S transforms as S → U S U†. - /// - /// For Heisenberg picture evolution (U†AU), use [`conjdg`](Self::conjdg). - /// - /// # Example - /// - /// ``` - /// use pecos_core::operator::{X, Z, H, T}; - /// - /// // Stabilizer update: applying H to qubit 0 - /// let stabilizer = X(0) & Z(1); - /// let updated = stabilizer.conj(&H(0)); // H * (X⊗Z) * H† - /// - /// // Works with any operators - /// let a = T(0); - /// let b = X(0); - /// let conjugated = b.conj(&a); // T X T† - /// ``` - #[must_use] - pub fn conj(&self, gate: &Operator) -> Self { - gate.clone() * self.clone() * gate.dg() - } - - /// Conjugates this operator by the adjoint of another: `gate.dg() * self * gate` (i.e., U†AU). - /// - /// This is the Heisenberg picture convention: operators evolve as A → U†AU. - /// - /// For stabilizer updates (UAU†), use [`conj`](Self::conj). - /// - /// # Example - /// - /// ``` - /// use pecos_core::operator::{X, H}; - /// - /// // Heisenberg evolution: how X evolves under H - /// let evolved = X(0).conjdg(&H(0)); // H† X H - /// ``` - #[must_use] - pub fn conjdg(&self, gate: &Operator) -> Self { - gate.dg() * self.clone() * gate.clone() - } - - /// Returns the global phase of this operator. - /// - /// # Example - /// - /// ``` - /// use pecos_core::operator::{X, Y}; - /// use pecos_core::{GlobalPhase, QuarterPhase}; - /// - /// let op = X(0); - /// assert_eq!(op.phase(), GlobalPhase::one()); - /// - /// let op = -Y(0); - /// assert_eq!(op.phase(), GlobalPhase::minus_one()); - /// ``` - #[must_use] - pub fn phase(&self) -> crate::GlobalPhase { - use crate::GlobalPhase; - - match self { - Self::Pauli(ps) => GlobalPhase::from(ps.phase()), - Self::Phase { phase, .. } => GlobalPhase::from(*phase), - _ => GlobalPhase::one(), - } - } - - /// Returns the weight (number of qubits) this operator acts on. - /// - /// # Example - /// - /// ``` - /// use pecos_core::operator::{X, Z, CX}; - /// - /// assert_eq!(X(0).weight(), 1); - /// assert_eq!((X(0) & Z(2)).weight(), 2); - /// assert_eq!(CX(0, 1).weight(), 2); - /// ``` - #[must_use] - pub fn weight(&self) -> usize { - self.qubits().len() - } - - /// Checks if this is structurally the identity operator. - /// - /// # Example - /// - /// ``` - /// use pecos_core::operator::I; - /// - /// assert!(I(0).is_identity()); - /// ``` - #[must_use] - pub fn is_identity(&self) -> bool { - match self { - Self::Pauli(ps) => ps.weight() == 0 && ps.phase() == crate::QuarterPhase::PlusOne, - Self::Gate { gate_type, .. } => *gate_type == GateType::I, - Self::Rotation { angle, .. } => *angle == Angle64::ZERO, - Self::Tensor(parts) | Self::Compose(parts) => parts.iter().all(Operator::is_identity), - Self::Adjoint(inner) => inner.is_identity(), - Self::Phase { phase, inner } => *phase == Angle64::ZERO && inner.is_identity(), - } - } - - /// Checks if this operator is Hermitian (self-adjoint): A = A†. - /// - /// # Example - /// - /// ``` - /// use pecos_core::operator::{X, Y, Z, H, T}; - /// - /// // Paulis are Hermitian - /// assert!(X(0).is_hermitian()); - /// assert!(Y(0).is_hermitian()); - /// assert!(Z(0).is_hermitian()); - /// - /// // H is Hermitian - /// assert!(H(0).is_hermitian()); - /// - /// // T is not Hermitian (T† ≠ T) - /// assert!(!T(0).is_hermitian()); - /// ``` - #[must_use] - pub fn is_hermitian(&self) -> bool { - // A is Hermitian if A = A† - // For structural comparison, we check known Hermitian operators - match self { - Self::Pauli(_) => true, // All Paulis are Hermitian - Self::Gate { gate_type, .. } => matches!( - gate_type, - GateType::I - | GateType::X - | GateType::Y - | GateType::Z - | GateType::H - | GateType::CX - | GateType::CY - | GateType::CZ - | GateType::SWAP - ), - Self::Rotation { angle, .. } => { - // Rotations are Hermitian only at angle 0 or π - *angle == Angle64::ZERO || *angle == Angle64::HALF_TURN - } - Self::Tensor(parts) => parts.iter().all(Operator::is_hermitian), - // Composition of Hermitians isn't generally Hermitian; phase factors break Hermiticity - Self::Compose(_) | Self::Phase { .. } => false, - Self::Adjoint(inner) => inner.is_hermitian(), // (A†)† = A, so same as inner - } - } - - /// Returns the operator raised to a power (repeated composition). - /// - /// # Example - /// - /// ``` - /// use pecos_core::operator::{X, H}; - /// - /// let x = X(0); - /// let x2 = x.pow(2); // X * X = I - /// - /// let h = H(0); - /// let h3 = h.pow(3); // H * H * H = H - /// ``` - #[must_use] - pub fn pow(&self, n: u32) -> Self { - match n { - 0 => Self::Gate { - gate_type: GateType::I, - qubits: self - .qubits() - .into_iter() - .next() - .map_or(smallvec::smallvec![0], |q| smallvec::smallvec![q]), - }, - 1 => self.clone(), - _ => { - let mut result = self.clone(); - for _ in 1..n { - result = result * self.clone(); - } - result - } - } - } - - /// Checks whether this operator commutes with another. - /// - /// For Pauli operators, returns `Commutes` or `AntiCommutes`. - /// For non-Pauli operators, returns `Unknown`. - /// - /// # Example - /// - /// ``` - /// use pecos_core::operator::{X, Z, Commutativity}; - /// - /// let a = X(0); - /// let b = Z(0); - /// assert_eq!(a.commutes(&b), Commutativity::AntiCommutes); - /// - /// let c = X(0) & Z(1); - /// let d = Z(0) & X(1); - /// assert_eq!(c.commutes(&d), Commutativity::Commutes); - /// ``` - #[must_use] - pub fn commutes(&self, other: &Operator) -> Commutativity { - use crate::PauliOperator; - - match (self, other) { - (Self::Pauli(a), Self::Pauli(b)) => { - if a.commutes_with(b) { - Commutativity::Commutes - } else { - Commutativity::AntiCommutes - } - } - _ => Commutativity::Unknown, - } - } - - /// Returns whether this operator is unitary. - /// - /// All operators in this enum are unitary by construction. - /// - /// # Example - /// - /// ``` - /// use pecos_core::operator::{X, H, RZ}; - /// use pecos_core::Angle64; - /// - /// assert!(X(0).is_unitary()); - /// assert!(H(0).is_unitary()); - /// assert!(RZ(Angle64::QUARTER_TURN, 0).is_unitary()); - /// ``` - #[must_use] - pub fn is_unitary(&self) -> bool { - true // All Operator variants are unitary by construction - } - - /// Decomposes this operator into a sequence of primitive gates. - /// - /// Returns a flat vector of `Gate` structs representing the operator - /// as a sequence of native gates. - /// - /// # Example - /// - /// ``` - /// use pecos_core::operator::{H, CX}; - /// - /// let circuit = CX(0, 1) * H(0); // H then CX - /// let gates = circuit.decompose(); - /// assert_eq!(gates.len(), 2); - /// ``` - #[must_use] - pub fn decompose(&self) -> Vec { - use crate::{Gate, Pauli}; - - match self { - Self::Pauli(ps) => { - // Convert PauliString to individual gates - let mut gates = Vec::new(); - for (pauli, qubit) in ps.iter_pairs() { - let gate = match pauli { - Pauli::I => continue, // Skip identity - Pauli::X => Gate::simple(GateType::X, smallvec::smallvec![qubit]), - Pauli::Y => Gate::simple(GateType::Y, smallvec::smallvec![qubit]), - Pauli::Z => Gate::simple(GateType::Z, smallvec::smallvec![qubit]), - }; - gates.push(gate); - } - // Handle global phase if not +1 - // (Phase is tracked separately in PauliString but not representable in Gate) - gates - } - - Self::Rotation { - rotation_type, - angle, - qubits, - } => { - let gate_type = match rotation_type { - RotationType::RX => GateType::RX, - RotationType::RY => GateType::RY, - RotationType::RZ => GateType::RZ, - RotationType::RXX => GateType::RXX, - RotationType::RYY => GateType::RYY, - RotationType::RZZ => GateType::RZZ, - }; - let qubit_ids: crate::GateQubits = - qubits.iter().map(|&q| crate::QubitId(q)).collect(); - vec![Gate::with_angles( - gate_type, - smallvec::smallvec![*angle], - qubit_ids, - )] - } - - Self::Gate { gate_type, qubits } => { - let qubit_ids: crate::GateQubits = - qubits.iter().map(|&q| crate::QubitId(q)).collect(); - vec![Gate::simple(*gate_type, qubit_ids)] - } - - Self::Tensor(parts) => { - // Decompose each part and concatenate - parts.iter().flat_map(Operator::decompose).collect() - } - - Self::Compose(parts) => { - // Decompose each part in application order - parts.iter().flat_map(Operator::decompose).collect() - } - - Self::Adjoint(inner) => { - // Decompose inner, reverse, and adjoint each gate - let mut gates = inner.decompose(); - gates.reverse(); - for gate in &mut gates { - // Negate angles for rotation gates - for angle in &mut gate.angles { - *angle = Angle64::ZERO - *angle; - } - // Some gates need special handling - gate.gate_type = match gate.gate_type { - GateType::SX => GateType::SXdg, - GateType::SXdg => GateType::SX, - GateType::SY => GateType::SYdg, - GateType::SYdg => GateType::SY, - GateType::SZ => GateType::SZdg, - GateType::SZdg => GateType::SZ, - GateType::T => GateType::Tdg, - GateType::Tdg => GateType::T, - GateType::SZZ => GateType::SZZdg, - GateType::SZZdg => GateType::SZZ, - other => other, // Self-adjoint gates unchanged - }; - } - gates - } - - Self::Phase { inner, .. } => { - // Global phase doesn't affect gate sequence - // (Phase information is lost in decomposition) - inner.decompose() - } - } - } - - /// Converts this gate expression to a `CliffordRep` (generator propagation). - /// - /// Returns `None` if the expression contains non-Clifford operations. - /// - /// # Arguments - /// * `num_qubits` - The total number of qubits in the system - #[must_use] - pub fn to_clifford_rep(&self, num_qubits: usize) -> Option { - use crate::clifford_rep::CliffordRep; - - if !self.is_clifford() { - return None; - } - - match self { - Self::Pauli(ps) => { - // Convert PauliString to CliffordRep by composing single-qubit Paulis - let mut result = CliffordRep::identity(num_qubits); - for (pauli, qubit) in ps.iter_pairs() { - let q = usize::from(qubit); - let cliff = match pauli { - crate::Pauli::I => continue, // Skip identity - crate::Pauli::X => CliffordRep::x_on(q, num_qubits), - crate::Pauli::Y => CliffordRep::y_on(q, num_qubits), - crate::Pauli::Z => CliffordRep::z_on(q, num_qubits), - }; - result = cliff.compose(&result); - } - Some(result) - } - - Self::Rotation { - rotation_type, - angle, - qubits, - } => rotation_to_clifford_rep(*rotation_type, *angle, qubits, num_qubits), - - Self::Gate { gate_type, qubits } => { - gate_type_to_clifford_rep(*gate_type, qubits, num_qubits) - } - - Self::Tensor(parts) => { - // For tensor products, compose all parts (they act on different qubits) - let mut result = CliffordRep::identity(num_qubits); - for part in parts { - if let Some(cliff) = part.to_clifford_rep(num_qubits) { - result = result.compose(&cliff); - } else { - return None; - } - } - Some(result) - } - - Self::Compose(parts) => { - // For composition, compose in order (parts are in application order) - let mut result = CliffordRep::identity(num_qubits); - for part in parts { - if let Some(cliff) = part.to_clifford_rep(num_qubits) { - result = cliff.compose(&result); - } else { - return None; - } - } - Some(result) - } - - Self::Adjoint(inner) => { - // Get the inner CliffordRep and take its inverse - inner - .to_clifford_rep(num_qubits) - .map(|cliff| cliff.inverse()) - } - - Self::Phase { inner, .. } => { - // Global phase is ignored in CliffordRep (Heisenberg picture) - inner.to_clifford_rep(num_qubits) - } - } - } -} - -/// Convert a rotation to `CliffordRep` if it's Clifford. -fn rotation_to_clifford_rep( - rotation_type: RotationType, - angle: Angle64, - qubits: &SmallVec<[usize; 2]>, - num_qubits: usize, -) -> Option { - use crate::clifford_rep::CliffordRep; - - // Check for Clifford angles (multiples of π/2) - let quarter = Angle64::QUARTER_TURN; - let neg_quarter = negate_angle(quarter); - let half = Angle64::HALF_TURN; - let neg_half = negate_angle(half); - let three_quarter = quarter + half; - let _neg_three_quarter = negate_angle(three_quarter); - - // Identity - if angle == Angle64::ZERO { - return Some(CliffordRep::identity(num_qubits)); - } - - match rotation_type { - RotationType::RZ => { - let qubit = qubits[0]; - let mut result = CliffordRep::identity(num_qubits); - - if angle == quarter { - // S = RZ(π/2) - result = apply_s(&result, qubit); - } else if angle == neg_quarter || angle == three_quarter { - // S† = RZ(-π/2) = RZ(3π/2) - result = apply_sdg(&result, qubit); - } else if angle == half || angle == neg_half { - // Z = RZ(π) - result = apply_z(&result, qubit); - } else { - return None; // Not a Clifford angle - } - Some(result) - } - - RotationType::RX => { - let qubit = qubits[0]; - let mut result = CliffordRep::identity(num_qubits); - - if angle == quarter { - // SX = RX(π/2) - result = apply_sx(&result, qubit); - } else if angle == neg_quarter || angle == three_quarter { - // SX† = RX(-π/2) - result = apply_sxdg(&result, qubit); - } else if angle == half || angle == neg_half { - // X = RX(π) - result = apply_x(&result, qubit); - } else { - return None; - } - Some(result) - } - - RotationType::RY => { - let qubit = qubits[0]; - let mut result = CliffordRep::identity(num_qubits); - - if angle == quarter { - // SY = RY(π/2) - result = apply_sy(&result, qubit); - } else if angle == neg_quarter || angle == three_quarter { - // SY† = RY(-π/2) - result = apply_sydg(&result, qubit); - } else if angle == half || angle == neg_half { - // Y = RY(π) - result = apply_y(&result, qubit); - } else { - return None; - } - Some(result) - } - - RotationType::RZZ => { - let q0 = qubits[0]; - let q1 = qubits[1]; - - if angle == quarter { - Some(CliffordRep::cz(q0, q1).compose(&CliffordRep::identity(num_qubits))) - } else if angle == neg_quarter || angle == three_quarter { - // SZZ† - CZ with phase adjustment - Some(CliffordRep::cz(q0, q1).compose(&CliffordRep::identity(num_qubits))) - } else { - None - } - } - - _ => None, // RXX, RYY at non-zero angles are not standard Cliffords - } -} - -/// Convert a `GateType` to `CliffordRep`. -fn gate_type_to_clifford_rep( - gate_type: GateType, - qubits: &SmallVec<[usize; 3]>, - num_qubits: usize, -) -> Option { - use crate::clifford_rep::CliffordRep; - - match gate_type { - GateType::I => Some(CliffordRep::identity(num_qubits)), - GateType::X => { - let mut result = CliffordRep::identity(num_qubits); - result = apply_x(&result, qubits[0]); - Some(result) - } - GateType::Y => { - let mut result = CliffordRep::identity(num_qubits); - result = apply_y(&result, qubits[0]); - Some(result) - } - GateType::Z => { - let mut result = CliffordRep::identity(num_qubits); - result = apply_z(&result, qubits[0]); - Some(result) - } - GateType::H => { - let cliff = CliffordRep::h(qubits[0]); - // Extend to num_qubits - Some(extend_clifford(cliff, num_qubits)) - } - GateType::SX => { - let mut result = CliffordRep::identity(num_qubits); - result = apply_sx(&result, qubits[0]); - Some(result) - } - GateType::SY => { - let mut result = CliffordRep::identity(num_qubits); - result = apply_sy(&result, qubits[0]); - Some(result) - } - GateType::SZ => { - let mut result = CliffordRep::identity(num_qubits); - result = apply_s(&result, qubits[0]); - Some(result) - } - GateType::SXdg => { - let mut result = CliffordRep::identity(num_qubits); - result = apply_sxdg(&result, qubits[0]); - Some(result) - } - GateType::SYdg => { - let mut result = CliffordRep::identity(num_qubits); - result = apply_sydg(&result, qubits[0]); - Some(result) - } - GateType::SZdg => { - let mut result = CliffordRep::identity(num_qubits); - result = apply_sdg(&result, qubits[0]); - Some(result) - } - GateType::CX => { - let cliff = CliffordRep::cx(qubits[0], qubits[1]); - Some(extend_clifford(cliff, num_qubits)) - } - GateType::CY => { - let cliff = CliffordRep::cy(qubits[0], qubits[1]); - Some(extend_clifford(cliff, num_qubits)) - } - GateType::CZ => { - let cliff = CliffordRep::cz(qubits[0], qubits[1]); - Some(extend_clifford(cliff, num_qubits)) - } - GateType::SWAP => { - let cliff = CliffordRep::swap(qubits[0], qubits[1]); - Some(extend_clifford(cliff, num_qubits)) - } - _ => None, // Non-Clifford or unsupported gate - } -} - -/// Extend a `CliffordRep` to act on more qubits (identity on new qubits). -fn extend_clifford( - cliff: crate::clifford_rep::CliffordRep, - target_qubits: usize, -) -> crate::clifford_rep::CliffordRep { - use crate::clifford_rep::CliffordRep; - - if cliff.num_qubits() >= target_qubits { - return cliff; - } - - // Create a new CliffordRep with more qubits, copying the original images - // and using identity for the additional qubits - let mut result = CliffordRep::identity(target_qubits); - - // Copy the generator images from the original - for q in 0..cliff.num_qubits() { - result.set_x_image(q, cliff.x_image(q).clone()); - result.set_z_image(q, cliff.z_image(q).clone()); - } - // Additional qubits remain as identity (already set by CliffordRep::identity) - - result -} - -// Helper functions to apply single-qubit Cliffords to a CliffordRep -fn apply_x( - cliff: &crate::clifford_rep::CliffordRep, - qubit: usize, -) -> crate::clifford_rep::CliffordRep { - let x_cliff = crate::clifford_rep::CliffordRep::x(qubit); - extend_clifford(x_cliff, cliff.num_qubits()).compose(cliff) -} - -fn apply_y( - cliff: &crate::clifford_rep::CliffordRep, - qubit: usize, -) -> crate::clifford_rep::CliffordRep { - let y_cliff = crate::clifford_rep::CliffordRep::y(qubit); - extend_clifford(y_cliff, cliff.num_qubits()).compose(cliff) -} - -fn apply_z( - cliff: &crate::clifford_rep::CliffordRep, - qubit: usize, -) -> crate::clifford_rep::CliffordRep { - let z_cliff = crate::clifford_rep::CliffordRep::z(qubit); - extend_clifford(z_cliff, cliff.num_qubits()).compose(cliff) -} - -fn apply_s( - cliff: &crate::clifford_rep::CliffordRep, - qubit: usize, -) -> crate::clifford_rep::CliffordRep { - let s_cliff = crate::clifford_rep::CliffordRep::s(qubit); - extend_clifford(s_cliff, cliff.num_qubits()).compose(cliff) -} - -fn apply_sdg( - cliff: &crate::clifford_rep::CliffordRep, - qubit: usize, -) -> crate::clifford_rep::CliffordRep { - let sdg_cliff = crate::clifford_rep::CliffordRep::sdg(qubit); - extend_clifford(sdg_cliff, cliff.num_qubits()).compose(cliff) -} - -fn apply_sx( - cliff: &crate::clifford_rep::CliffordRep, - qubit: usize, -) -> crate::clifford_rep::CliffordRep { - let sx_cliff = crate::clifford_rep::CliffordRep::sx(qubit); - extend_clifford(sx_cliff, cliff.num_qubits()).compose(cliff) -} - -fn apply_sxdg( - cliff: &crate::clifford_rep::CliffordRep, - qubit: usize, -) -> crate::clifford_rep::CliffordRep { - // SX† = SX^3 = SX * SX * SX - let sx_cliff = crate::clifford_rep::CliffordRep::sx(qubit); - let extended = extend_clifford(sx_cliff.clone(), cliff.num_qubits()); - extended - .compose(&extended) - .compose(&extended) - .compose(cliff) -} - -fn apply_sy( - cliff: &crate::clifford_rep::CliffordRep, - qubit: usize, -) -> crate::clifford_rep::CliffordRep { - let sy_cliff = crate::clifford_rep::CliffordRep::sy(qubit); - extend_clifford(sy_cliff, cliff.num_qubits()).compose(cliff) -} - -fn apply_sydg( - cliff: &crate::clifford_rep::CliffordRep, - qubit: usize, -) -> crate::clifford_rep::CliffordRep { - // SY† = SY^3 - let sy_cliff = crate::clifford_rep::CliffordRep::sy(qubit); - let extended = extend_clifford(sy_cliff.clone(), cliff.num_qubits()); - extended - .compose(&extended) - .compose(&extended) - .compose(cliff) -} - -/// Flatten nested Compose nodes into a single level. -fn flatten_compose(parts: Vec) -> Vec { - let mut result = Vec::new(); - for part in parts { - match part { - Operator::Compose(inner_parts) => { - result.extend(flatten_compose(inner_parts)); - } - other => result.push(other), - } - } - result -} - -/// Merge adjacent rotations of the same type on the same qubits. -fn merge_adjacent_rotations(parts: Vec) -> Vec { - if parts.len() < 2 { - return parts; - } - - let mut result = Vec::with_capacity(parts.len()); - let mut idx = 0; - - while idx < parts.len() { - let current = &parts[idx]; - - // Check if next element can be merged with current - if idx + 1 < parts.len() - && let Some(merged) = try_merge_rotations(current, &parts[idx + 1]) - { - // Skip the merged element - if merged.is_identity() { - // Both cancelled out, skip both - idx += 2; - continue; - } - result.push(merged); - idx += 2; - continue; - } - - result.push(parts[idx].clone()); - idx += 1; - } - - // Recurse if we made any merges (might enable more merges) - if result.len() < parts.len() { - merge_adjacent_rotations(result) - } else { - result - } -} - -/// Try to merge two rotations if they are compatible. -/// Returns None if they cannot be merged. -fn try_merge_rotations(a: &Operator, b: &Operator) -> Option { - match (a, b) { - ( - Operator::Rotation { - rotation_type: rt_a, - angle: angle_a, - qubits: qubits_a, - }, - Operator::Rotation { - rotation_type: rt_b, - angle: angle_b, - qubits: qubits_b, - }, - ) => { - // Can only merge if same rotation type and same qubits - if rt_a == rt_b && qubits_a == qubits_b { - let combined_angle = *angle_a + *angle_b; - Some(Operator::Rotation { - rotation_type: *rt_a, - angle: combined_angle, - qubits: qubits_a.clone(), - }) - } else { - None - } - } - _ => None, - } -} - -/// Convert a rotation (type + angle) to a named `GateType` if one exists. -#[must_use] -pub fn rotation_to_gate_type(rotation_type: RotationType, angle: Angle64) -> Option { - // Check for standard angles - let quarter = Angle64::QUARTER_TURN; - let neg_quarter = negate_angle(quarter); - let half = Angle64::HALF_TURN; - let eighth = half / 4; // π/4 - let neg_eighth = negate_angle(eighth); - - match rotation_type { - RotationType::RZ => { - if angle == quarter { - Some(GateType::SZ) - } else if angle == neg_quarter { - Some(GateType::SZdg) - } else if angle == half { - Some(GateType::Z) - } else if angle == eighth { - Some(GateType::T) - } else if angle == neg_eighth { - Some(GateType::Tdg) - } else { - None - } - } - RotationType::RX => { - if angle == quarter { - Some(GateType::SX) - } else if angle == neg_quarter { - Some(GateType::SXdg) - } else if angle == half { - Some(GateType::X) - } else { - None - } - } - RotationType::RY => { - if angle == quarter { - Some(GateType::SY) - } else if angle == neg_quarter { - Some(GateType::SYdg) - } else if angle == half { - Some(GateType::Y) - } else { - None - } - } - RotationType::RZZ => { - if angle == quarter { - Some(GateType::SZZ) - } else if angle == neg_quarter { - Some(GateType::SZZdg) - } else { - None - } - } - _ => None, - } -} - -// --- Gate type helpers --- - -trait GateTypeExt { - fn is_clifford(&self) -> bool; - fn is_self_adjoint(&self) -> bool; -} - -impl GateTypeExt for GateType { - fn is_clifford(&self) -> bool { - use GateType::{CX, CY, CZ, H, I, SWAP, SX, SXdg, SY, SYdg, SZ, SZZ, SZZdg, SZdg, X, Y, Z}; - matches!( - self, - I | X - | Y - | Z - | H - | SX - | SXdg - | SY - | SYdg - | SZ - | SZdg - | CX - | CY - | CZ - | SWAP - | SZZ - | SZZdg - ) - } - - fn is_self_adjoint(&self) -> bool { - use GateType::{CX, CY, CZ, H, I, SWAP, X, Y, Z}; - matches!(self, I | X | Y | Z | H | CX | CY | CZ | SWAP) - } -} - -// --- Angle64 helpers --- - -/// Check if an angle is a multiple of a quarter turn (π/2). -/// -/// This is used to determine if a rotation is a Clifford gate. -fn is_multiple_of_quarter_turn(angle: Angle64) -> bool { - let quarter_fraction = Angle64::QUARTER_TURN.fraction(); - if quarter_fraction == 0 { - return true; // Edge case: if quarter is 0, everything is a multiple - } - angle.fraction().is_multiple_of(quarter_fraction) -} - -/// Negate an angle (for adjoint operations). -fn negate_angle(angle: Angle64) -> Angle64 { - Angle64::ZERO - angle -} - -/// Convert an angle to a `QuarterPhase` if it's a multiple of π/2. -/// -/// Returns None if the angle is not a multiple of π/2. -fn angle_to_quarter_phase(angle: Angle64) -> Option { - let quarter = Angle64::QUARTER_TURN; - let half = Angle64::HALF_TURN; - let three_quarters = quarter + half; - - if angle == Angle64::ZERO { - Some(QuarterPhase::PlusOne) - } else if angle == quarter { - Some(QuarterPhase::PlusI) - } else if angle == half { - Some(QuarterPhase::MinusOne) - } else if angle == three_quarters { - Some(QuarterPhase::MinusI) - } else { - None - } -} - -// --- Gate constructors - Single qubit rotations --- - -/// Rotation around X axis by the given angle. -/// -/// For multiple qubits, use `RXs(angle, [0, 1, 2])`. -#[must_use] -#[allow(non_snake_case)] -pub fn RX(angle: Angle64, qubit: impl Into) -> Operator { - Operator::rotation(RotationType::RX, angle, smallvec::smallvec![qubit.into().0]) -} - -/// RX rotations on multiple qubits. -/// -/// `RXs(angle, [0, 1, 2])` is equivalent to `RX(angle, 0) & RX(angle, 1) & RX(angle, 2)` -#[must_use] -#[allow(non_snake_case)] -pub fn RXs(angle: Angle64, qubits: impl Into) -> Operator { - qubits - .into() - .apply(|q| Operator::rotation(RotationType::RX, angle, smallvec::smallvec![q])) -} - -/// Rotation around Y axis by the given angle. -/// -/// For multiple qubits, use `RYs(angle, [0, 1, 2])`. -#[must_use] -#[allow(non_snake_case)] -pub fn RY(angle: Angle64, qubit: impl Into) -> Operator { - Operator::rotation(RotationType::RY, angle, smallvec::smallvec![qubit.into().0]) -} - -/// RY rotations on multiple qubits. -/// -/// `RYs(angle, [0, 1, 2])` is equivalent to `RY(angle, 0) & RY(angle, 1) & RY(angle, 2)` -#[must_use] -#[allow(non_snake_case)] -pub fn RYs(angle: Angle64, qubits: impl Into) -> Operator { - qubits - .into() - .apply(|q| Operator::rotation(RotationType::RY, angle, smallvec::smallvec![q])) -} - -/// Rotation around Z axis by the given angle. -/// -/// For multiple qubits, use `RZs(angle, [0, 1, 2])`. -#[must_use] -#[allow(non_snake_case)] -pub fn RZ(angle: Angle64, qubit: impl Into) -> Operator { - Operator::rotation(RotationType::RZ, angle, smallvec::smallvec![qubit.into().0]) -} - -/// RZ rotations on multiple qubits. -/// -/// `RZs(angle, [0, 1, 2])` is equivalent to `RZ(angle, 0) & RZ(angle, 1) & RZ(angle, 2)` -#[must_use] -#[allow(non_snake_case)] -pub fn RZs(angle: Angle64, qubits: impl Into) -> Operator { - qubits - .into() - .apply(|q| Operator::rotation(RotationType::RZ, angle, smallvec::smallvec![q])) -} - -// --- Gate constructors - Two qubit rotations --- - -/// Two-qubit XX rotation by the given angle. -/// -/// For multiple pairs, use `RXXs(angle, [(0, 1), (2, 3)])` or tensor. -#[must_use] -#[allow(non_snake_case)] -pub fn RXX(angle: Angle64, q0: impl Into, q1: impl Into) -> Operator { - Operator::rotation( - RotationType::RXX, - angle, - smallvec::smallvec![q0.into().0, q1.into().0], - ) -} - -/// RXX rotations on multiple qubit pairs. -/// -/// `RXXs(angle, [(0, 1), (2, 3)])` is equivalent to `RXX(angle, 0, 1) & RXX(angle, 2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn RXXs(angle: Angle64, pairs: impl Into) -> Operator { - pairs - .into() - .apply(|q0, q1| Operator::rotation(RotationType::RXX, angle, smallvec::smallvec![q0, q1])) -} - -/// Two-qubit YY rotation by the given angle. -/// -/// For multiple pairs, use `RYYs(angle, [(0, 1), (2, 3)])` or tensor. -#[must_use] -#[allow(non_snake_case)] -pub fn RYY(angle: Angle64, q0: impl Into, q1: impl Into) -> Operator { - Operator::rotation( - RotationType::RYY, - angle, - smallvec::smallvec![q0.into().0, q1.into().0], - ) -} - -/// RYY rotations on multiple qubit pairs. -/// -/// `RYYs(angle, [(0, 1), (2, 3)])` is equivalent to `RYY(angle, 0, 1) & RYY(angle, 2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn RYYs(angle: Angle64, pairs: impl Into) -> Operator { - pairs - .into() - .apply(|q0, q1| Operator::rotation(RotationType::RYY, angle, smallvec::smallvec![q0, q1])) -} - -/// Two-qubit ZZ rotation by the given angle. -/// -/// For multiple pairs, use `RZZs(angle, [(0, 1), (2, 3)])` or tensor. -#[must_use] -#[allow(non_snake_case)] -pub fn RZZ(angle: Angle64, q0: impl Into, q1: impl Into) -> Operator { - Operator::rotation( - RotationType::RZZ, - angle, - smallvec::smallvec![q0.into().0, q1.into().0], - ) -} - -/// RZZ rotations on multiple qubit pairs. -/// -/// `RZZs(angle, [(0, 1), (2, 3)])` is equivalent to `RZZ(angle, 0, 1) & RZZ(angle, 2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn RZZs(angle: Angle64, pairs: impl Into) -> Operator { - pairs - .into() - .apply(|q0, q1| Operator::rotation(RotationType::RZZ, angle, smallvec::smallvec![q0, q1])) -} - -// --- Gate constructors - Named single-qubit Cliffords --- - -/// Identity gate on a single qubit. -#[must_use] -#[allow(non_snake_case)] -pub fn I(qubit: impl Into) -> Operator { - RZ(Angle64::ZERO, qubit.into().0) -} - -/// Identity gates on multiple qubits. -#[must_use] -#[allow(non_snake_case)] -pub fn Is(qubits: impl Into) -> Operator { - qubits.into().apply(|q| RZ(Angle64::ZERO, q)) -} - -/// Pauli X operator on a single qubit. -/// -/// For multiple qubits, use `Xs([0, 2, 5])` or tensor: `X(0) & X(2) & X(5)` -#[must_use] -#[allow(non_snake_case)] -pub fn X(qubit: impl Into) -> Operator { - Operator::Pauli(PauliString::x(qubit.into().0)) -} - -/// Pauli X operators on multiple qubits. -/// -/// `Xs([0, 2, 5])` is equivalent to `X(0) & X(2) & X(5)` -#[must_use] -#[allow(non_snake_case)] -pub fn Xs(qubits: impl Into) -> Operator { - let qs = qubits.into(); - if qs.0.is_empty() { - Operator::Pauli(PauliString::default()) - } else { - let mut ps = PauliString::x(qs.0[0].0); - for q in &qs.0[1..] { - ps = ps & PauliString::x(q.0); - } - Operator::Pauli(ps) - } -} - -/// Pauli Y operator on a single qubit. -/// -/// For multiple qubits, use `Ys([0, 2, 5])` or tensor: `Y(0) & Y(2) & Y(5)` -#[must_use] -#[allow(non_snake_case)] -pub fn Y(qubit: impl Into) -> Operator { - Operator::Pauli(PauliString::y(qubit.into().0)) -} - -/// Pauli Y operators on multiple qubits. -/// -/// `Ys([0, 2, 5])` is equivalent to `Y(0) & Y(2) & Y(5)` -#[must_use] -#[allow(non_snake_case)] -pub fn Ys(qubits: impl Into) -> Operator { - let qs = qubits.into(); - if qs.0.is_empty() { - Operator::Pauli(PauliString::default()) - } else { - let mut ps = PauliString::y(qs.0[0].0); - for q in &qs.0[1..] { - ps = ps & PauliString::y(q.0); - } - Operator::Pauli(ps) - } -} - -/// Pauli Z operator on a single qubit. -/// -/// For multiple qubits, use `Zs([0, 2, 5])` or tensor: `Z(0) & Z(2) & Z(5)` -#[must_use] -#[allow(non_snake_case)] -pub fn Z(qubit: impl Into) -> Operator { - Operator::Pauli(PauliString::z(qubit.into().0)) -} - -/// Pauli Z operators on multiple qubits. -/// -/// `Zs([0, 2, 5])` is equivalent to `Z(0) & Z(2) & Z(5)` -#[must_use] -#[allow(non_snake_case)] -pub fn Zs(qubits: impl Into) -> Operator { - let qs = qubits.into(); - if qs.0.is_empty() { - Operator::Pauli(PauliString::default()) - } else { - let mut ps = PauliString::z(qs.0[0].0); - for q in &qs.0[1..] { - ps = ps & PauliString::z(q.0); - } - Operator::Pauli(ps) - } -} - -/// SX gate (sqrt X): RX(π/2) -#[must_use] -#[allow(non_snake_case)] -pub fn SX(qubit: impl Into) -> Operator { - RX(Angle64::QUARTER_TURN, qubit.into().0) -} - -/// SX gates on multiple qubits. -#[must_use] -#[allow(non_snake_case)] -pub fn SXs(qubits: impl Into) -> Operator { - qubits.into().apply(|q| RX(Angle64::QUARTER_TURN, q)) -} - -/// SY gate (sqrt Y): RY(π/2) -#[must_use] -#[allow(non_snake_case)] -pub fn SY(qubit: impl Into) -> Operator { - RY(Angle64::QUARTER_TURN, qubit.into().0) -} - -/// SY gates on multiple qubits. -#[must_use] -#[allow(non_snake_case)] -pub fn SYs(qubits: impl Into) -> Operator { - qubits.into().apply(|q| RY(Angle64::QUARTER_TURN, q)) -} - -/// SZ gate (sqrt Z): RZ(π/2) -#[must_use] -#[allow(non_snake_case)] -pub fn SZ(qubit: impl Into) -> Operator { - RZ(Angle64::QUARTER_TURN, qubit.into().0) -} - -/// SZ gates on multiple qubits. -#[must_use] -#[allow(non_snake_case)] -pub fn SZs(qubits: impl Into) -> Operator { - qubits.into().apply(|q| RZ(Angle64::QUARTER_TURN, q)) -} - -/// T gate: RZ(π/4) -#[must_use] -#[allow(non_snake_case)] -pub fn T(qubit: impl Into) -> Operator { - RZ(Angle64::HALF_TURN / 4, qubit.into().0) -} - -/// T gates on multiple qubits. -#[must_use] -#[allow(non_snake_case)] -pub fn Ts(qubits: impl Into) -> Operator { - qubits.into().apply(|q| RZ(Angle64::HALF_TURN / 4, q)) -} - -/// Hadamard gate: RZ(π) * RY(π/2) (up to global phase) -#[must_use] -#[allow(non_snake_case)] -pub fn H(qubit: impl Into) -> Operator { - let q = qubit.into().0; - Operator::Gate { - gate_type: GateType::H, - qubits: smallvec::smallvec![q], - } -} - -/// Hadamard gates on multiple qubits. -#[must_use] -#[allow(non_snake_case)] -pub fn Hs(qubits: impl Into) -> Operator { - qubits.into().apply(|q| { - Operator::Compose(vec![ - RZ(Angle64::HALF_TURN, q), - RY(Angle64::QUARTER_TURN, q), - ]) - }) -} - -// --- Gate constructors - Named two-qubit gates --- - -/// CNOT (CX) gate. -/// -/// For multiple pairs, use `CXs([(0, 1), (2, 3)])` or tensor: `CX(0, 1) & CX(2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn CX(control: impl Into, target: impl Into) -> Operator { - Operator::gate( - GateType::CX, - smallvec::smallvec![control.into().0, target.into().0], - ) -} - -/// CX gates on multiple qubit pairs. -/// -/// `CXs([(0, 1), (2, 3)])` is equivalent to `CX(0, 1) & CX(2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn CXs(pairs: impl Into) -> Operator { - pairs - .into() - .apply(|ctrl, tgt| Operator::gate(GateType::CX, smallvec::smallvec![ctrl, tgt])) -} - -/// Controlled-Y gate. -/// -/// For multiple pairs, use `CYs([(0, 1), (2, 3)])` or tensor: `CY(0, 1) & CY(2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn CY(control: impl Into, target: impl Into) -> Operator { - Operator::gate( - GateType::CY, - smallvec::smallvec![control.into().0, target.into().0], - ) -} - -/// CY gates on multiple qubit pairs. -/// -/// `CYs([(0, 1), (2, 3)])` is equivalent to `CY(0, 1) & CY(2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn CYs(pairs: impl Into) -> Operator { - pairs - .into() - .apply(|ctrl, tgt| Operator::gate(GateType::CY, smallvec::smallvec![ctrl, tgt])) -} - -/// Controlled-Z gate. -/// -/// For multiple pairs, use `CZs([(0, 1), (2, 3)])` or tensor: `CZ(0, 1) & CZ(2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn CZ(q0: impl Into, q1: impl Into) -> Operator { - Operator::gate(GateType::CZ, smallvec::smallvec![q0.into().0, q1.into().0]) -} - -/// CZ gates on multiple qubit pairs. -/// -/// `CZs([(0, 1), (2, 3)])` is equivalent to `CZ(0, 1) & CZ(2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn CZs(pairs: impl Into) -> Operator { - pairs - .into() - .apply(|q0, q1| Operator::gate(GateType::CZ, smallvec::smallvec![q0, q1])) -} - -/// SWAP gate. -/// -/// For multiple pairs, use `SWAPs([(0, 1), (2, 3)])` or tensor: `SWAP(0, 1) & SWAP(2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn SWAP(q0: impl Into, q1: impl Into) -> Operator { - Operator::gate( - GateType::SWAP, - smallvec::smallvec![q0.into().0, q1.into().0], - ) -} - -/// SWAP gates on multiple qubit pairs. -/// -/// `SWAPs([(0, 1), (2, 3)])` is equivalent to `SWAP(0, 1) & SWAP(2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn SWAPs(pairs: impl Into) -> Operator { - pairs - .into() - .apply(|q0, q1| Operator::gate(GateType::SWAP, smallvec::smallvec![q0, q1])) -} - -/// SZZ gate: RZZ(π/2) -/// -/// For multiple pairs, use `SZZs([(0, 1), (2, 3)])` or tensor: `SZZ(0, 1) & SZZ(2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn SZZ(q0: impl Into, q1: impl Into) -> Operator { - Operator::rotation( - RotationType::RZZ, - Angle64::QUARTER_TURN, - smallvec::smallvec![q0.into().0, q1.into().0], - ) -} - -/// SZZ gates on multiple qubit pairs. -/// -/// `SZZs([(0, 1), (2, 3)])` is equivalent to `SZZ(0, 1) & SZZ(2, 3)` -#[must_use] -#[allow(non_snake_case)] -pub fn SZZs(pairs: impl Into) -> Operator { - pairs.into().apply(|q0, q1| { - Operator::rotation( - RotationType::RZZ, - Angle64::QUARTER_TURN, - smallvec::smallvec![q0, q1], - ) - }) -} - -// --- Gate constructors - Three-qubit gates --- - -/// Toffoli (CCX) gate. -#[must_use] -#[allow(non_snake_case)] -pub fn CCX(c0: impl Into, c1: impl Into, target: impl Into) -> Operator { - Operator::gate( - GateType::CCX, - smallvec::smallvec![c0.into().0, c1.into().0, target.into().0], - ) -} - -// --- Operator implementations --- - -// Tensor product: & -impl BitAnd for Operator { - type Output = Operator; - - fn bitand(self, rhs: Operator) -> Operator { - match (self, rhs) { - // Pauli & Pauli: use PauliString tensor product - (Operator::Pauli(a), Operator::Pauli(b)) => Operator::Pauli(a & b), - // Flatten nested tensors - (Operator::Tensor(mut parts), Operator::Tensor(rhs_parts)) => { - parts.extend(rhs_parts); - Operator::Tensor(parts) - } - (Operator::Tensor(mut parts), rhs) => { - parts.push(rhs); - Operator::Tensor(parts) - } - (lhs, Operator::Tensor(mut parts)) => { - parts.insert(0, lhs); - Operator::Tensor(parts) - } - (lhs, rhs) => Operator::Tensor(vec![lhs, rhs]), - } - } -} - -// Composition: * -impl Mul for Operator { - type Output = Operator; - - fn mul(self, rhs: Operator) -> Operator { - // A * B means apply B first, then A (matrix multiplication order) - // So we store as [B, A] in the Compose vec (application order) - match (self, rhs) { - // Pauli * Pauli: use PauliString algebra - (Operator::Pauli(a), Operator::Pauli(b)) => Operator::Pauli(a * b), - // Flatten nested compositions - (Operator::Compose(lhs_parts), Operator::Compose(rhs_parts)) => { - // rhs applied first, then lhs - let mut result = rhs_parts; - result.extend(lhs_parts); - Operator::Compose(result) - } - (Operator::Compose(lhs_parts), rhs) => { - // rhs applied first - let mut result = vec![rhs]; - result.extend(lhs_parts); - Operator::Compose(result) - } - (lhs, Operator::Compose(mut rhs_parts)) => { - // rhs_parts applied first, then lhs - rhs_parts.push(lhs); - Operator::Compose(rhs_parts) - } - (lhs, rhs) => { - // rhs applied first, then lhs - Operator::Compose(vec![rhs, lhs]) - } - } - } -} - -// --- Circuit diagram generation --- - -impl Operator { - /// Generates an ASCII circuit diagram for this expression. - #[must_use] - pub fn to_diagram(&self, num_qubits: usize) -> String { - let mut diagram = CircuitDiagram::new(num_qubits); - self.add_to_diagram(&mut diagram); - diagram.render() - } - - fn add_to_diagram(&self, diagram: &mut CircuitDiagram) { - match self { - Self::Pauli(ps) => { - // Draw each Pauli on its qubit - for (pauli, qubit) in ps.iter_pairs() { - let q = usize::from(qubit); - let name = match pauli { - crate::Pauli::I => continue, - crate::Pauli::X => "X", - crate::Pauli::Y => "Y", - crate::Pauli::Z => "Z", - }; - diagram.add_single_gate(q, name); - } - } - Self::Rotation { - rotation_type, - angle, - qubits, - } => { - let name = if let Some(gate_type) = rotation_to_gate_type(*rotation_type, *angle) { - format!("{gate_type:?}") - } else { - format!("{rotation_type:?}") - }; - - if qubits.len() == 1 { - diagram.add_single_gate(qubits[0], &name); - } else if qubits.len() == 2 { - diagram.add_two_qubit_gate(qubits[0], qubits[1], &name); - } - } - Self::Gate { gate_type, qubits } => match gate_type { - GateType::CX => { - diagram.add_controlled_gate(qubits[0], qubits[1], "X"); - } - GateType::CY => { - diagram.add_controlled_gate(qubits[0], qubits[1], "Y"); - } - GateType::CZ => { - diagram.add_controlled_gate(qubits[0], qubits[1], "Z"); - } - GateType::SWAP => { - diagram.add_swap(qubits[0], qubits[1]); - } - GateType::CCX => { - diagram.add_toffoli(qubits[0], qubits[1], qubits[2]); - } - _ => { - if qubits.len() == 1 { - diagram.add_single_gate(qubits[0], &format!("{gate_type:?}")); - } - } - }, - Self::Tensor(parts) => { - // Tensor products can be drawn simultaneously - for part in parts { - part.add_to_diagram(diagram); - } - } - Self::Compose(parts) => { - // Sequential composition: draw in order - for part in parts { - part.add_to_diagram(diagram); - diagram.advance(); - } - } - Self::Adjoint(inner) => { - // Mark as adjoint somehow? - inner.add_to_diagram(diagram); - } - Self::Phase { inner, .. } => { - // Global phase doesn't appear in circuit diagrams - inner.add_to_diagram(diagram); - } - } - } -} - -struct CircuitDiagram { - num_qubits: usize, - columns: Vec>, - current_col: usize, -} - -impl CircuitDiagram { - fn new(num_qubits: usize) -> Self { - Self { - num_qubits, - columns: vec![vec![String::new(); num_qubits * 2 - 1]], - current_col: 0, - } - } - - fn ensure_column(&mut self) { - if self.current_col >= self.columns.len() { - self.columns - .push(vec![String::new(); self.num_qubits * 2 - 1]); - } - } - - fn advance(&mut self) { - self.current_col += 1; - } - - fn add_single_gate(&mut self, qubit: usize, name: &str) { - self.ensure_column(); - let row = qubit * 2; - if row < self.columns[self.current_col].len() { - self.columns[self.current_col][row] = format!("[{name}]"); - } - } - - fn add_controlled_gate(&mut self, control: usize, target: usize, target_name: &str) { - self.ensure_column(); - let ctrl_row = control * 2; - let targ_row = target * 2; - - if ctrl_row < self.columns[self.current_col].len() { - self.columns[self.current_col][ctrl_row] = "●".to_string(); - } - if targ_row < self.columns[self.current_col].len() { - self.columns[self.current_col][targ_row] = format!("[{target_name}]"); - } - - // Draw vertical line - let (min_row, max_row) = if ctrl_row < targ_row { - (ctrl_row, targ_row) - } else { - (targ_row, ctrl_row) - }; - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn add_swap(&mut self, q0: usize, q1: usize) { - self.ensure_column(); - let row0 = q0 * 2; - let row1 = q1 * 2; - - if row0 < self.columns[self.current_col].len() { - self.columns[self.current_col][row0] = "×".to_string(); - } - if row1 < self.columns[self.current_col].len() { - self.columns[self.current_col][row1] = "×".to_string(); - } - - // Draw vertical line - let (min_row, max_row) = (row0.min(row1), row0.max(row1)); - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn add_toffoli(&mut self, c0: usize, c1: usize, target: usize) { - self.ensure_column(); - let c0_row = c0 * 2; - let c1_row = c1 * 2; - let targ_row = target * 2; - - if c0_row < self.columns[self.current_col].len() { - self.columns[self.current_col][c0_row] = "●".to_string(); - } - if c1_row < self.columns[self.current_col].len() { - self.columns[self.current_col][c1_row] = "●".to_string(); - } - if targ_row < self.columns[self.current_col].len() { - self.columns[self.current_col][targ_row] = "[X]".to_string(); - } - - // Draw vertical lines - let min_row = c0_row.min(c1_row).min(targ_row); - let max_row = c0_row.max(c1_row).max(targ_row); - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn add_two_qubit_gate(&mut self, q0: usize, q1: usize, name: &str) { - self.ensure_column(); - let row0 = q0 * 2; - let row1 = q1 * 2; - - if row0 < self.columns[self.current_col].len() { - self.columns[self.current_col][row0] = format!("[{name}]"); - } - if row1 < self.columns[self.current_col].len() { - self.columns[self.current_col][row1] = format!("[{name}]"); - } - - // Draw vertical line - let (min_row, max_row) = (row0.min(row1), row0.max(row1)); - for row in (min_row + 1)..max_row { - if row % 2 == 1 && self.columns[self.current_col][row].is_empty() { - self.columns[self.current_col][row] = "│".to_string(); - } - } - } - - fn render(&self) -> String { - let lines: Vec = (0..self.num_qubits).map(|q| format!("q{q}: ")).collect(); - - // Add spacing lines between qubits - let mut all_lines: Vec = Vec::new(); - for (idx, line) in lines.iter().enumerate() { - all_lines.push(line.clone()); - if idx < self.num_qubits - 1 { - all_lines.push(" ".to_string()); // spacing line - } - } - - // Process each column - for col in &self.columns { - // Find max width in this column - let max_width = col - .iter() - .map(|s| s.chars().count()) - .max() - .unwrap_or(0) - .max(3); - - for (row, cell) in col.iter().enumerate() { - if row < all_lines.len() { - if cell.is_empty() { - // Wire or empty - if row % 2 == 0 { - all_lines[row].push_str(&"─".repeat(max_width)); - } else { - all_lines[row].push_str(&" ".repeat(max_width)); - } - } else { - // Center the cell content - let padding = max_width.saturating_sub(cell.chars().count()); - let left_pad = padding / 2; - let right_pad = padding - left_pad; - - if row % 2 == 0 { - // Qubit line - all_lines[row].push_str(&"─".repeat(left_pad)); - all_lines[row].push_str(cell); - all_lines[row].push_str(&"─".repeat(right_pad)); - } else { - // Spacing line - all_lines[row].push_str(&" ".repeat(left_pad)); - all_lines[row].push_str(cell); - all_lines[row].push_str(&" ".repeat(right_pad)); - } - } - } - } - } - - // Add trailing wire - for (idx, line) in all_lines.iter_mut().enumerate() { - if idx % 2 == 0 { - line.push('─'); - } - } - - all_lines.join("\n") - } -} - - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_single_qubit_gates() { - let x = X(0); - let z = Z(1); - let h = H(0); - - assert!(x.is_clifford()); - assert!(z.is_clifford()); - assert!(h.is_clifford()); - } - - #[test] - fn test_t_gate_not_clifford() { - let t = T(0); - assert!(!t.is_clifford()); - } - - #[test] - fn test_tensor_product() { - let op = X(0) & Z(1); - assert!(op.is_clifford()); - - // Pauli & Pauli now produces a single Pauli variant - if let Operator::Pauli(ps) = &op { - assert_eq!(ps.get(0), crate::Pauli::X); - assert_eq!(ps.get(1), crate::Pauli::Z); - } else { - panic!("Expected Pauli, got {op:?}"); - } - - // Mixed tensor (Pauli with non-Pauli) produces Tensor - let mixed = X(0) & H(1); - assert!(matches!(mixed, Operator::Tensor(_))); - } - - #[test] - fn test_composition() { - let circuit = T(0) * H(0); - assert!(!circuit.is_clifford()); // T is not Clifford - - let cliff_circuit = SZ(0) * H(0); - assert!(cliff_circuit.is_clifford()); - } - - #[test] - fn test_control_gates() { - let cx = CX(0, 1); - let cz = CZ(0, 1); - - assert!(cx.is_clifford()); - assert!(cz.is_clifford()); - } - - #[test] - fn test_adjoint() { - let t = T(0); - let t_dg = t.dg(); - - // T† should have negated angle - if let Operator::Rotation { angle, .. } = t_dg { - let t_angle = Angle64::HALF_TURN / 4; - let expected = negate_angle(t_angle); - assert_eq!(angle, expected); - } else { - panic!("Expected Rotation"); - } - } - - #[test] - fn test_double_adjoint() { - let h = H(0); - let h_dg_dg = h.dg().dg(); - - // H†† should equal H (structurally) - assert_eq!(h, h_dg_dg); - } - - #[test] - fn test_qubits() { - let circuit = CX(0, 1) * H(0) * T(2); - let qubits = circuit.qubits(); - assert_eq!(qubits, vec![0, 1, 2]); - } - - #[test] - fn test_to_named_gate() { - let sz = SZ(0); - assert_eq!(sz.to_named_gate(), Some(GateType::SZ)); - - let t = T(0); - assert_eq!(t.to_named_gate(), Some(GateType::T)); - - let x = X(0); - assert_eq!(x.to_named_gate(), Some(GateType::X)); - } - - #[test] - fn test_diagram_single_qubit() { - let h = H(0); - let diagram = h.to_diagram(1); - assert!(diagram.contains("[H]")); - } - - #[test] - fn test_diagram_cx() { - let cx = CX(0, 1); - let diagram = cx.to_diagram(2); - assert!(diagram.contains("●")); - assert!(diagram.contains("[X]")); - } - - #[test] - fn test_diagram_complex() { - // Build a circuit: H(0), CX(0,1), T(1) - let circuit = T(1) * CX(0, 1) * H(0); - let diagram = circuit.to_diagram(2); - println!("Circuit diagram:\n{diagram}"); - - // Also test a 3-qubit circuit - let circuit3 = CCX(0, 1, 2) * H(0) * H(1); - let diagram3 = circuit3.to_diagram(3); - println!("\n3-qubit circuit:\n{diagram3}"); - } - - #[test] - fn test_composition_order() { - // A * B means apply B first, then A - // Test composition with rotations - let circuit = SZ(0) * SY(0) * SX(0); // Apply SX, then SY, then SZ - - if let Operator::Compose(parts) = circuit { - assert_eq!(parts.len(), 3); - // All should be rotations - assert!(matches!(&parts[0], Operator::Rotation { .. })); - assert!(matches!(&parts[1], Operator::Rotation { .. })); - assert!(matches!(&parts[2], Operator::Rotation { .. })); - } else { - panic!("Expected Compose"); - } - } - -// --- Simplify tests --- - - #[test] - fn test_simplify_identity() { - let id = I(0); - assert!(id.is_identity()); - } - - #[test] - fn test_simplify_cancellation() { - // T * T† should simplify to identity - let t = T(0); - let t_dg = t.dg(); - let circuit = t.clone() * t_dg; - let simplified = circuit.simplify(); - - // Should be identity (zero-angle rotation) - assert!(simplified.is_identity()); - } - - #[test] - fn test_simplify_merge_adjacent() { - // SZ * SZ = Z (RZ(π/2) + RZ(π/2) = RZ(π)) - let circuit = SZ(0) * SZ(0); - let simplified = circuit.simplify(); - - // Should merge into a single rotation - if let Operator::Rotation { - angle, - rotation_type, - .. - } = simplified - { - assert_eq!(rotation_type, RotationType::RZ); - assert_eq!(angle, Angle64::HALF_TURN); // π - } else { - panic!("Expected single Rotation, got {simplified:?}"); - } - } - - #[test] - fn test_simplify_preserves_different_qubits() { - // RZ on different qubits shouldn't merge - let circuit = RZ(Angle64::QUARTER_TURN, 0) * RZ(Angle64::QUARTER_TURN, 1); - let simplified = circuit.simplify(); - - // Should remain a Compose with 2 parts - if let Operator::Compose(parts) = simplified { - assert_eq!(parts.len(), 2); - } else { - panic!("Expected Compose"); - } - } - - #[test] - fn test_simplify_preserves_different_rotation_types() { - // RX and RZ on same qubit shouldn't merge - let circuit = RX(Angle64::QUARTER_TURN, 0) * RZ(Angle64::QUARTER_TURN, 0); - let simplified = circuit.simplify(); - - // Should remain a Compose with 2 parts - if let Operator::Compose(parts) = simplified { - assert_eq!(parts.len(), 2); - } else { - panic!("Expected Compose"); - } - } - - #[test] - fn test_simplify_multiple_merges() { - // T * T * T * T = Z (4 * π/4 = π) - let circuit = T(0) * T(0) * T(0) * T(0); - let simplified = circuit.simplify(); - - if let Operator::Rotation { angle, .. } = simplified { - assert_eq!(angle, Angle64::HALF_TURN); - } else { - panic!("Expected single Rotation"); - } - } - - #[test] - fn test_simplify_tensor_preserves_identity() { - // X(0) & I(1) should preserve the identity to maintain Hilbert space dimension - let circuit = X(0) & I(1); - let simplified = circuit.simplify(); - - // Should still be a tensor with both parts (preserves 2-qubit space) - let qubits = simplified.qubits(); - assert!(qubits.contains(&0)); - assert!(qubits.contains(&1)); - } - - #[test] - fn test_simplify_compose_with_gate() { - // CX doesn't merge with rotations - let circuit = SZ(0) * CX(0, 1) * SZ(0); - let simplified = circuit.simplify(); - - // Should have 3 parts (S, CX, S) - if let Operator::Compose(parts) = simplified { - assert_eq!(parts.len(), 3); - } else { - panic!("Expected Compose"); - } - } - -// --- CliffordRep conversion tests --- - - #[test] - fn test_to_clifford_rep_non_clifford_returns_none() { - let t = T(0); - assert!(t.to_clifford_rep(1).is_none()); - } - - #[test] - fn test_to_clifford_rep_identity() { - let id = I(0); - let cliff = id.to_clifford_rep(1).unwrap(); - - // Identity should transform X -> X, Z -> Z - let x0 = PauliString::x(0); - let z0 = PauliString::z(0); - - let tx = cliff.apply(&x0); - let tz = cliff.apply(&z0); - - assert_eq!(tx.get(0), crate::Pauli::X); - assert_eq!(tz.get(0), crate::Pauli::Z); - } - - #[test] - fn test_to_clifford_rep_x_gate() { - let x = X(0); - let cliff = x.to_clifford_rep(1).unwrap(); - - // X gate: X -> X, Z -> -Z - let z0 = PauliString::z(0); - - let tz = cliff.apply(&z0); - assert_eq!(tz.get(0), crate::Pauli::Z); - assert_eq!(tz.phase(), crate::QuarterPhase::MinusOne); - } - - #[test] - fn test_to_clifford_rep_s_gate() { - let s = SZ(0); - let cliff = s.to_clifford_rep(1).unwrap(); - - // SZ gate: X -> Y, Z -> Z - let x0 = PauliString::x(0); - - let tx = cliff.apply(&x0); - assert_eq!(tx.get(0), crate::Pauli::Y); - } - - #[test] - fn test_to_clifford_rep_cx_gate() { - let cx = CX(0, 1); - let cliff = cx.to_clifford_rep(2).unwrap(); - - // CX: X_control -> X_control * X_target - let x0 = PauliString::x(0); - - let tx = cliff.apply(&x0); - // Should have X on both qubits - assert_eq!(tx.get(0), crate::Pauli::X); - assert_eq!(tx.get(1), crate::Pauli::X); - } - - #[test] - fn test_to_clifford_rep_composition() { - // S * H should be convertible - let circuit = SZ(0) * H(0); - let cliff = circuit.to_clifford_rep(1); - assert!(cliff.is_some()); - } - -// --- Phase tests --- - - #[test] - fn test_phase_basic() { - // phase(π/4) * X should create a phased operator - // Since π/4 is not a quarter-turn multiple, it wraps in Phase - let eighth_turn = Angle64::HALF_TURN / 4; - let op = phase(eighth_turn) * X(0); - - if let Operator::Phase { phase: p, inner } = op { - assert_eq!(p, eighth_turn); - // Inner should be Pauli(X) - assert!(matches!(*inner, Operator::Pauli(_))); - } else { - panic!("Expected Phase variant, got {op:?}"); - } - } - - #[test] - fn test_phase_negation() { - // -phase(θ) = phase(θ + π) - let quarter = Angle64::QUARTER_TURN; - let p = phase(quarter); - let neg_p = -p; - - assert_eq!(neg_p.0, quarter + Angle64::HALF_TURN); - } - - #[test] - fn test_phase_times_i() { - // i * phase(θ) = phase(θ + π/2) - let quarter = Angle64::QUARTER_TURN; - let p = phase(quarter); - let ip = i * p; - - assert_eq!(ip.0, quarter + Angle64::QUARTER_TURN); - } - - #[test] - fn test_phase_times_neg_i() { - // -i * phase(θ) = phase(θ + 3π/2) - let quarter = Angle64::QUARTER_TURN; - let p = phase(quarter); - let nip = -i * p; - - assert_eq!(nip.0, quarter + Angle64::QUARTER_TURN + Angle64::HALF_TURN); - } - - #[test] - fn test_phase_equivalence_with_i() { - // phase(π/2) * X should be equivalent to i * X - // Since π/2 is a quarter turn, the phase gets absorbed into the PauliString - let op1 = phase(Angle64::QUARTER_TURN) * X(0); - let op2 = i * X(0); - - // Both should be Pauli with phase +i - if let (Operator::Pauli(ps1), Operator::Pauli(ps2)) = (&op1, &op2) { - assert_eq!(ps1.phase(), QuarterPhase::PlusI); - assert_eq!(ps2.phase(), QuarterPhase::PlusI); - } else { - panic!("Expected Pauli variants, got {op1:?} and {op2:?}"); - } - } - - #[test] - fn test_phase_zero_is_identity() { - // phase(0) * X should simplify to just X - let op = phase(Angle64::ZERO) * X(0); - - // with_phase returns self when phase is zero - assert!(matches!(op, Operator::Pauli(_))); - if let Operator::Pauli(ps) = op { - assert_eq!(ps.phase(), QuarterPhase::PlusOne); - } - } - -// --- Macro tests --- - - #[test] - fn test_angle_macro_pi() { - assert_eq!(crate::angle!(pi), Angle64::HALF_TURN); - } - - #[test] - fn test_angle_macro_pi_over_2() { - assert_eq!(crate::angle!(pi / 2), Angle64::QUARTER_TURN); - } - - #[test] - fn test_angle_macro_pi_over_4() { - // pi/4 = 1/8 turn - assert_eq!(crate::angle!(pi / 4), Angle64::from_turn_ratio(1, 8)); - } - - #[test] - fn test_angle_macro_2_pi_over_3() { - // 2*pi/3 = 2/6 = 1/3 turn - assert_eq!(crate::angle!(2 * pi / 3), Angle64::from_turn_ratio(1, 3)); - } - - #[test] - fn test_angle_macro_4_pi_over_3() { - // 4*pi/3 = 4/6 = 2/3 turn - assert_eq!(crate::angle!(4 * pi / 3), Angle64::from_turn_ratio(2, 3)); - } - - #[test] - fn test_angle_macro_negative() { - // -pi/2 should be the negative of pi/2 - let neg = crate::angle!(-pi / 2); - let pos = crate::angle!(pi / 2); - assert_eq!(neg, Angle64::ZERO - pos); - } - - #[test] - fn test_phase_macro_basic() { - // phase!(pi/4) should create a PhaseValue - let p = crate::phase!(pi / 4); - assert_eq!(p.0, Angle64::from_turn_ratio(1, 8)); - } - - #[test] - fn test_phase_macro_with_operator() { - // phase!(pi/2) * X should be same as i * X - // Since pi/2 is a quarter turn, the phase gets absorbed into PauliString - let op1 = crate::phase!(pi / 2) * X(0); - let op2 = i * X(0); - - // Both should be Pauli with phase +i - if let (Operator::Pauli(ps1), Operator::Pauli(ps2)) = (&op1, &op2) { - assert_eq!(ps1.phase(), QuarterPhase::PlusI); - assert_eq!(ps2.phase(), QuarterPhase::PlusI); - } else { - panic!("Expected Pauli variants, got {op1:?} and {op2:?}"); - } - } - - #[test] - fn test_phase_macro_exact_cancellation() { - // 8 * (pi/4) should exactly equal 2*pi = 0 - let eighth = crate::angle!(pi / 4); - let full = eighth + eighth + eighth + eighth + eighth + eighth + eighth + eighth; - assert_eq!(full, Angle64::ZERO); - } - -// --- Turn macro tests --- - - #[test] - fn test_turn_macro_quarter() { - assert_eq!(crate::turn!(1 / 4), Angle64::QUARTER_TURN); - } - - #[test] - fn test_turn_macro_half() { - assert_eq!(crate::turn!(1 / 2), Angle64::HALF_TURN); - } - - #[test] - fn test_turn_macro_eighth() { - // 1/8 turn = T gate phase = pi/4 radians - assert_eq!(crate::turn!(1 / 8), crate::angle!(pi / 4)); - } - - #[test] - fn test_turn_macro_third() { - // 1/3 turn - assert_eq!(crate::turn!(1 / 3), Angle64::from_turn_ratio(1, 3)); - } - - #[test] - fn test_turn_macro_two_thirds() { - // 2/3 turn - assert_eq!(crate::turn!(2 / 3), Angle64::from_turn_ratio(2, 3)); - } - - #[test] - fn test_turn_vs_angle_equivalence() { - // turn!(1/4) should equal angle!(pi/2) - assert_eq!(crate::turn!(1 / 4), crate::angle!(pi / 2)); - - // turn!(1/8) should equal angle!(pi/4) - assert_eq!(crate::turn!(1 / 8), crate::angle!(pi / 4)); - - // turn!(1/2) should equal angle!(pi) - assert_eq!(crate::turn!(1 / 2), crate::angle!(pi)); - } - - #[test] - fn test_phase_turn_macro_basic() { - let p = crate::phase_turn!(1 / 8); - assert_eq!(p.0, Angle64::from_turn_ratio(1, 8)); - } - - #[test] - fn test_phase_turn_macro_with_operator() { - // phase_turn!(1/4) * X should be same as i * X (quarter turn = i) - // Since quarter turn is a quarter phase, it gets absorbed into the PauliString - let op1 = crate::phase_turn!(1 / 4) * X(0); - let op2 = i * X(0); - - // Both should be Pauli with phase +i - if let (Operator::Pauli(ps1), Operator::Pauli(ps2)) = (&op1, &op2) { - assert_eq!(ps1.phase(), QuarterPhase::PlusI); - assert_eq!(ps2.phase(), QuarterPhase::PlusI); - } else { - panic!("Expected Pauli variants, got {op1:?} and {op2:?}"); - } - } - - #[test] - fn test_turn_exact_cancellation() { - // 8 * (1/8 turn) should exactly equal 1 full turn = 0 - let eighth = crate::turn!(1 / 8); - let full = eighth + eighth + eighth + eighth + eighth + eighth + eighth + eighth; - assert_eq!(full, Angle64::ZERO); - } - -// --- Pauli equivalence tests --- - - #[test] - fn test_is_pauli_equivalent_pauli() { - assert!(X(0).is_pauli_equivalent()); - assert!(Y(1).is_pauli_equivalent()); - assert!(Z(2).is_pauli_equivalent()); - assert!((X(0) & Y(1)).is_pauli_equivalent()); - } - - #[test] - fn test_is_pauli_equivalent_rotation() { - // Half-turn rotations are Pauli-equivalent - assert!(RX(Angle64::HALF_TURN, 0).is_pauli_equivalent()); - assert!(RY(Angle64::HALF_TURN, 0).is_pauli_equivalent()); - assert!(RZ(Angle64::HALF_TURN, 0).is_pauli_equivalent()); - - // Quarter-turn rotations are not - assert!(!RX(Angle64::QUARTER_TURN, 0).is_pauli_equivalent()); - assert!(!RZ(Angle64::QUARTER_TURN, 0).is_pauli_equivalent()); - } - - #[test] - #[allow(clippy::similar_names)] - fn test_try_to_pauli_rotation() { - // RX(π) = X - let rx_pi = RX(Angle64::HALF_TURN, 0); - let converted = rx_pi.try_to_pauli().expect("Should convert"); - if let Operator::Pauli(ps) = converted { - assert_eq!(ps.get(0), crate::Pauli::X); - } else { - panic!("Expected Pauli variant"); - } - - // RY(π) = Y - let ry_pi = RY(Angle64::HALF_TURN, 1); - let converted = ry_pi.try_to_pauli().expect("Should convert"); - if let Operator::Pauli(ps) = converted { - assert_eq!(ps.get(1), crate::Pauli::Y); - } else { - panic!("Expected Pauli variant"); - } - - // RZ(π) = Z - let rz_pi = RZ(Angle64::HALF_TURN, 2); - let converted = rz_pi.try_to_pauli().expect("Should convert"); - if let Operator::Pauli(ps) = converted { - assert_eq!(ps.get(2), crate::Pauli::Z); - } else { - panic!("Expected Pauli variant"); - } - } - - #[test] - fn test_try_to_pauli_non_pauli() { - // Quarter-turn rotations should not convert - assert!(RX(Angle64::QUARTER_TURN, 0).try_to_pauli().is_none()); - assert!(RZ(Angle64::QUARTER_TURN, 0).try_to_pauli().is_none()); - } - -// --- Multi-qubit syntax tests --- - - #[test] - fn test_x_multi_qubit() { - // Xs([0, 2, 5]) should be equivalent to X(0) & X(2) & X(5) - let multi = Xs([0, 2, 5]); - let tensor = X(0) & X(2) & X(5); - - // Both should be Pauli variants with the same content - if let (Operator::Pauli(ps1), Operator::Pauli(ps2)) = (&multi, &tensor) { - assert_eq!(ps1.get(0), crate::Pauli::X); - assert_eq!(ps1.get(2), crate::Pauli::X); - assert_eq!(ps1.get(5), crate::Pauli::X); - assert_eq!(ps1, ps2); - } else { - panic!("Expected Pauli variants"); - } - } - - #[test] - fn test_t_multi_qubit() { - // Ts([0, 1, 2]) should be a tensor of T gates - let multi = Ts([0, 1, 2]); - - if let Operator::Tensor(parts) = multi { - assert_eq!(parts.len(), 3); - // Each should be a rotation - assert!(matches!(&parts[0], Operator::Rotation { .. })); - assert!(matches!(&parts[1], Operator::Rotation { .. })); - assert!(matches!(&parts[2], Operator::Rotation { .. })); - } else { - panic!("Expected Tensor variant, got {multi:?}"); - } - } - - #[test] - fn test_h_multi_qubit() { - // Hs([0, 1]) should be a tensor of H gates - let multi = Hs([0, 1]); - - if let Operator::Tensor(parts) = multi { - assert_eq!(parts.len(), 2); - // Each should be a Compose (H = RZ * RY) - assert!(matches!(&parts[0], Operator::Compose(_))); - assert!(matches!(&parts[1], Operator::Compose(_))); - } else { - panic!("Expected Tensor variant, got {multi:?}"); - } - } - - #[test] - fn test_single_qubit_still_works() { - // Single qubit syntax should still work - let x = X(0); - let t = T(1); - let h = H(2); - - assert!(matches!(x, Operator::Pauli(_))); - assert!(matches!(t, Operator::Rotation { .. })); - assert!(matches!( - h, - Operator::Gate { - gate_type: GateType::H, - .. - } - )); - } - - #[test] - fn test_range_syntax() { - // Range syntax: Xs(0..3) = X(0) & X(1) & X(2) - let multi_range = Xs(0..3); - let tensor = X(0) & X(1) & X(2); - - if let (Operator::Pauli(ps1), Operator::Pauli(ps2)) = (&multi_range, &tensor) { - assert_eq!(ps1, ps2); - } else { - panic!("Expected Pauli variants"); - } - } - - #[test] - fn test_range_inclusive_syntax() { - // RangeInclusive syntax: Zs(1..=3) = Z(1) & Z(2) & Z(3) - let multi_range = Zs(1..=3); - let tensor = Z(1) & Z(2) & Z(3); - - if let (Operator::Pauli(ps1), Operator::Pauli(ps2)) = (&multi_range, &tensor) { - assert_eq!(ps1, ps2); - } else { - panic!("Expected Pauli variants"); - } - } - - #[test] - fn test_identity_range_syntax() { - // Is(0..=2) should create identity operators on qubits 0, 1, 2 - let identities = Is(0..=2); - - if let Operator::Tensor(parts) = identities { - assert_eq!(parts.len(), 3); - for part in &parts { - assert!(part.is_identity()); - } - } else { - panic!("Expected Tensor variant, got {identities:?}"); - } - } - - #[test] - #[should_panic(expected = "empty range not allowed")] - fn test_empty_range_panics() { - let _ = Xs(0..0); - } - - #[test] - #[should_panic(expected = "empty range not allowed")] - #[allow(clippy::reversed_empty_ranges)] // Intentionally testing empty range - fn test_empty_range_inclusive_panics() { - // 1..=0 is empty - let _ = Zs(1..=0); - } - - #[test] - fn test_single_element_range() { - // Xs(0..1) should be equivalent to X(0) - let from_range = Xs(0..1); - let direct = X(0); - - if let (Operator::Pauli(ps1), Operator::Pauli(ps2)) = (&from_range, &direct) { - assert_eq!(ps1, ps2); - } else { - panic!("Expected Pauli variants"); - } - } - - #[test] - fn test_single_element_range_inclusive() { - // Ts(2..=2) should be equivalent to T(2) - let from_range = Ts(2..=2); - let direct = T(2); - - // Both should be Rotation variants - if let ( - Operator::Rotation { - angle: a1, - rotation_type: r1, - .. - }, - Operator::Rotation { - angle: a2, - rotation_type: r2, - .. - }, - ) = (&from_range, &direct) - { - assert_eq!(a1, a2); - assert_eq!(r1, r2); - } else { - panic!("Expected Rotation variants, got {from_range:?} and {direct:?}"); - } - } - -// --- Conjugation tests --- - // Two conjugation conventions: - // A.conj(U) = U * A * U† (stabilizer update: S →USU† when applying U) - // A.conjdg(U) = U† * A * U (Heisenberg picture: A → U†AU) - - #[test] - fn test_conj_pauli_by_pauli() { - // X.conj(Z) = Z * X * Z† = Z * X * Z = -X (since Z is self-adjoint) - // ZX = -XZ, so ZXZ = -XZZ = -X - let x = X(0); - let z = Z(0); - let result = x.conj(&z); - - // conj returns a Compose, simplify to get the Pauli - let simplified = result.simplify(); - if let Operator::Pauli(ps) = simplified { - assert_eq!(ps.get(0), crate::Pauli::X); - assert_eq!(ps.phase(), QuarterPhase::MinusOne); - } else { - panic!("Expected Pauli variant, got {simplified:?}"); - } - } - - #[test] - fn test_conjdg_pauli_by_pauli() { - // X.conjdg(Z) = Z† * X * Z = Z * X * Z = -X (since Z is self-adjoint) - // Same result as conj for self-adjoint gates - let x = X(0); - let z = Z(0); - let result = x.conjdg(&z); - - let simplified = result.simplify(); - if let Operator::Pauli(ps) = simplified { - assert_eq!(ps.get(0), crate::Pauli::X); - assert_eq!(ps.phase(), QuarterPhase::MinusOne); - } else { - panic!("Expected Pauli variant, got {simplified:?}"); - } - } - - #[test] - fn test_conj_produces_compose() { - // A.conj(B) = B * A * B† produces a Compose - let x = X(0); - let h = H(0); - let result = x.conj(&h); - - // Result is Compose(H, X, H†) - assert!(matches!(result, Operator::Compose(_))); - } - - #[test] - fn test_conj_z_by_z() { - // Z.conj(Z) = Z * Z * Z† = Z * Z * Z = Z (since Z² = I, Z³ = Z) - let z = Z(0); - let result = z.conj(&z); - - let simplified = result.simplify(); - if let Operator::Pauli(ps) = simplified { - assert_eq!(ps.get(0), crate::Pauli::Z); - assert_eq!(ps.phase(), QuarterPhase::PlusOne); - } else { - panic!("Expected Pauli variant, got {simplified:?}"); - } - } - - #[test] - fn test_conj_structure_sz_gate() { - // X.conj(SZ) = SZ * X * SZ† should produce Compose with SZ first, X middle, SZ† last - let x = X(0); - let sz = SZ(0); - let result = x.conj(&sz); - - // Verify structure: Compose([SZ, X, SZ†]) - if let Operator::Compose(parts) = result { - assert_eq!(parts.len(), 3); - // First element is SZ (positive angle) - assert!(matches!( - &parts[0], - Operator::Rotation { - rotation_type: RotationType::RZ, - .. - } - )); - // Middle element is X - assert!(matches!(&parts[1], Operator::Pauli(_))); - // Last element is SZ† (negative angle) - assert!(matches!( - &parts[2], - Operator::Rotation { - rotation_type: RotationType::RZ, - .. - } - )); - } else { - panic!("Expected Compose variant, got {result:?}"); - } - } - - #[test] - fn test_conjdg_structure_sz_gate() { - // X.conjdg(SZ) = SZ† * X * SZ should produce Compose with SZ† first, X middle, SZ last - let x = X(0); - let sz = SZ(0); - let result = x.conjdg(&sz); - - // Verify structure: Compose([SZ†, X, SZ]) - if let Operator::Compose(parts) = result { - assert_eq!(parts.len(), 3); - // First element is SZ† (negative angle) - assert!(matches!( - &parts[0], - Operator::Rotation { - rotation_type: RotationType::RZ, - .. - } - )); - // Middle element is X - assert!(matches!(&parts[1], Operator::Pauli(_))); - // Last element is SZ (positive angle) - assert!(matches!( - &parts[2], - Operator::Rotation { - rotation_type: RotationType::RZ, - .. - } - )); - } else { - panic!("Expected Compose variant, got {result:?}"); - } - } - - #[test] - fn test_conj_conjdg_inverse_relationship() { - // For any operator A and gate U: - // A.conj(U).conjdg(U) should give back A (up to simplification) - // Because (UAU†).conjdg(U) = U†(UAU†)U = A - let x = X(0); - let sz = SZ(0); - - let forward = x.clone().conj(&sz); // SZ X SZ† - let back = forward.conjdg(&sz); // SZ† (SZ X SZ†) SZ = X - - let simplified = back.simplify(); - if let Operator::Pauli(ps) = simplified { - assert_eq!(ps.get(0), crate::Pauli::X); - assert_eq!(ps.phase(), QuarterPhase::PlusOne); - } else { - panic!("Expected Pauli variant, got {simplified:?}"); - } - } - -// --- Weight tests --- - - #[test] - fn test_weight_single_pauli() { - assert_eq!(X(0).weight(), 1); - assert_eq!(Y(1).weight(), 1); - assert_eq!(Z(2).weight(), 1); - } - - #[test] - fn test_weight_identity() { - // weight() returns number of qubits acted on - // I(0) = RZ(0, 0) acts on qubit 0 - assert_eq!(I(0).weight(), 1); - } - - #[test] - fn test_weight_tensor_product() { - let op = X(0) & Y(1) & Z(2); - assert_eq!(op.weight(), 3); - } - - #[test] - fn test_weight_tensor_with_identity() { - // Id tensored still counts as acting on that qubit - let op = X(0) & I(1) & Z(2); - assert_eq!(op.weight(), 3); - } - - #[test] - fn test_weight_rotation() { - // Rotations have weight equal to the number of qubits they act on - assert_eq!(RX(Angle64::QUARTER_TURN, 0).weight(), 1); - assert_eq!(RZZ(Angle64::QUARTER_TURN, 0, 1).weight(), 2); - } - - #[test] - fn test_weight_gate() { - assert_eq!(H(0).weight(), 1); - assert_eq!(CX(0, 1).weight(), 2); - assert_eq!(CCX(0, 1, 2).weight(), 3); - } - -// --- is_hermitian tests --- - - #[test] - fn test_is_hermitian_paulis() { - // All Paulis are Hermitian - assert!(X(0).is_hermitian()); - assert!(Y(0).is_hermitian()); - assert!(Z(0).is_hermitian()); - assert!(I(0).is_hermitian()); - } - - #[test] - fn test_is_hermitian_pauli_tensor() { - // Tensor products of Paulis are Hermitian - let op = X(0) & Y(1) & Z(2); - assert!(op.is_hermitian()); - } - - #[test] - fn test_is_hermitian_hadamard() { - // H is Hermitian (H = H†) - assert!(H(0).is_hermitian()); - } - - #[test] - fn test_is_hermitian_rotation_not() { - // General rotations are not Hermitian (unless angle is 0 or π) - assert!(!RZ(Angle64::QUARTER_TURN, 0).is_hermitian()); - assert!(!T(0).is_hermitian()); - assert!(!SZ(0).is_hermitian()); - } - - #[test] - fn test_is_hermitian_rotation_half_turn() { - // Half-turn rotations are Hermitian (up to global phase) - // RX(π) = -iX, which is Hermitian - assert!(RX(Angle64::HALF_TURN, 0).is_hermitian()); - assert!(RY(Angle64::HALF_TURN, 0).is_hermitian()); - assert!(RZ(Angle64::HALF_TURN, 0).is_hermitian()); - } - -// --- pow tests --- - - #[test] - fn test_pow_zero() { - // X^0 = I - let x = X(0); - let result = x.pow(0); - assert!(result.is_identity()); - } - - #[test] - fn test_pow_one() { - // X^1 = X - let x = X(0); - let result = x.pow(1); - if let Operator::Pauli(ps) = result { - assert_eq!(ps.get(0), crate::Pauli::X); - } else { - panic!("Expected Pauli"); - } - } - - #[test] - fn test_pow_two_pauli() { - // X^2 = I after simplification - let x = X(0); - let result = x.pow(2).simplify(); - assert!(result.is_identity()); - } - - #[test] - fn test_pow_creates_compose() { - // pow(n) creates a Compose of n copies without simplification - let t = T(0); - let result = t.pow(3); - assert!(matches!(result, Operator::Compose(_))); - } - - #[test] - fn test_pow_rotation_simplify() { - // T^2 = S (RZ(π/4)^2 = RZ(π/2)) after simplification - let t = T(0); - let result = t.pow(2).simplify(); - - if let Operator::Rotation { - angle, - rotation_type, - .. - } = result - { - assert_eq!(rotation_type, RotationType::RZ); - assert_eq!(angle, Angle64::QUARTER_TURN); - } else { - panic!("Expected Rotation, got {result:?}"); - } - } - - #[test] - fn test_pow_four_t_simplify() { - // T^4 = Z (RZ(π/4)^4 = RZ(π)) after simplification - let t = T(0); - let result = t.pow(4).simplify(); - - if let Operator::Rotation { angle, .. } = result { - assert_eq!(angle, Angle64::HALF_TURN); - } else { - panic!("Expected Rotation, got {result:?}"); - } - } - - #[test] - fn test_pow_eight_t_simplify() { - // T^8 = I (RZ(π/4)^8 = RZ(2π) = I) after simplification - let t = T(0); - let result = t.pow(8).simplify(); - assert!(result.is_identity()); - } - -// --- commutes tests --- - - #[test] - fn test_commutes_same_pauli() { - // X commutes with X - let x1 = X(0); - let x2 = X(0); - assert_eq!(x1.commutes(&x2), Commutativity::Commutes); - } - - #[test] - fn test_commutes_different_paulis_same_qubit() { - // X and Z anticommute on same qubit - let x = X(0); - let z = Z(0); - assert_eq!(x.commutes(&z), Commutativity::AntiCommutes); - - // X and Y anticommute - let y = Y(0); - assert_eq!(x.commutes(&y), Commutativity::AntiCommutes); - - // Y and Z anticommute - assert_eq!(y.commutes(&z), Commutativity::AntiCommutes); - } - - #[test] - fn test_commutes_different_qubits() { - // Operators on different qubits always commute - let x0 = X(0); - let z1 = Z(1); - assert_eq!(x0.commutes(&z1), Commutativity::Commutes); - } - - #[test] - fn test_commutes_non_pauli_unknown() { - // Non-Pauli operators return Unknown - // I(0) is RZ(0, 0), not a Pauli variant - let id = I(0); - let x = X(0); - assert_eq!(id.commutes(&x), Commutativity::Unknown); - - let h = H(0); - assert_eq!(h.commutes(&x), Commutativity::Unknown); - } - - #[test] - fn test_commutes_pauli_strings() { - // XY and YX: overlap on both qubits, both anticommute -> commute - let xy = X(0) & Y(1); - let yx = Y(0) & X(1); - assert_eq!(xy.commutes(&yx), Commutativity::Commutes); - - // XY and ZZ: both overlap, X-Z and Y-Z both anticommute -> commute - let zz = Z(0) & Z(1); - assert_eq!(xy.commutes(&zz), Commutativity::Commutes); - - // XZ and Y: only qubit 0 overlaps, X-Y anticommute - let xz = X(0) & Z(1); - let y = Y(0); - assert_eq!(xz.commutes(&y), Commutativity::AntiCommutes); - } - -// --- decompose tests --- - - #[test] - fn test_decompose_single_pauli() { - let x = X(0); - let gates = x.decompose(); - assert_eq!(gates.len(), 1); - assert_eq!(gates[0].gate_type, GateType::X); - } - - #[test] - fn test_decompose_pauli_tensor() { - let op = X(0) & Y(1) & Z(2); - let gates = op.decompose(); - assert_eq!(gates.len(), 3); - - let gate_types: Vec<_> = gates.iter().map(|g| g.gate_type).collect(); - assert!(gate_types.contains(&GateType::X)); - assert!(gate_types.contains(&GateType::Y)); - assert!(gate_types.contains(&GateType::Z)); - } - - #[test] - fn test_decompose_identity() { - // I(0) = RZ(0, 0), so it decomposes to an RZ gate with angle 0 - let id = I(0); - let gates = id.decompose(); - assert_eq!(gates.len(), 1); - assert_eq!(gates[0].gate_type, GateType::RZ); - assert_eq!(gates[0].angles[0], Angle64::ZERO); - } - - #[test] - fn test_decompose_pauli_identity_empty() { - // A true Pauli identity (from PauliString) decomposes to empty - let ps = PauliString::identity(); - let op = Operator::Pauli(ps); - let gates = op.decompose(); - assert!(gates.is_empty()); - } - - #[test] - fn test_decompose_rotation() { - let t = T(0); - let gates = t.decompose(); - assert_eq!(gates.len(), 1); - assert_eq!(gates[0].gate_type, GateType::RZ); - assert_eq!(gates[0].angles.len(), 1); - } - - #[test] - fn test_decompose_gate() { - let cx = CX(0, 1); - let gates = cx.decompose(); - assert_eq!(gates.len(), 1); - assert_eq!(gates[0].gate_type, GateType::CX); - } - - #[test] - fn test_decompose_composition() { - let circuit = SZ(0) * H(0) * X(0); // X, then H, then S - let gates = circuit.decompose(); - assert_eq!(gates.len(), 3); - } - - #[test] - fn test_decompose_adjoint() { - let t = T(0); - let t_dg = t.dg(); - let gates = t_dg.decompose(); - - assert_eq!(gates.len(), 1); - assert_eq!(gates[0].gate_type, GateType::RZ); - // Angle should be negated - let expected_angle = Angle64::ZERO - (Angle64::HALF_TURN / 4); - assert_eq!(gates[0].angles[0], expected_angle); - } - - #[test] - fn test_decompose_adjoint_named_gate() { - // S† should decompose to SZdg - let s = SZ(0); - let s_dg = s.dg(); - let gates = s_dg.decompose(); - - // S is a rotation, S† negates the angle - assert_eq!(gates.len(), 1); - assert_eq!(gates[0].gate_type, GateType::RZ); - } - -// --- as_pauli_string / into_pauli_string tests --- - - #[test] - fn test_as_pauli_string_pauli() { - let x = X(0); - let ps = x.as_pauli_string(); - assert!(ps.is_some()); - let ps = ps.unwrap(); - assert_eq!(ps.get(0), crate::Pauli::X); - } - - #[test] - fn test_as_pauli_string_non_pauli() { - let h = H(0); - assert!(h.as_pauli_string().is_none()); - - let t = T(0); - assert!(t.as_pauli_string().is_none()); - } - - #[test] - fn test_into_pauli_string_pauli() { - let xy = X(0) & Y(1); - let ps = xy.into_pauli_string(); - assert!(ps.is_some()); - let ps = ps.unwrap(); - assert_eq!(ps.get(0), crate::Pauli::X); - assert_eq!(ps.get(1), crate::Pauli::Y); - } - - #[test] - fn test_into_pauli_string_with_phase() { - let op = i * X(0); - let ps = op.into_pauli_string(); - assert!(ps.is_some()); - let ps = ps.unwrap(); - assert_eq!(ps.get(0), crate::Pauli::X); - assert_eq!(ps.phase(), QuarterPhase::PlusI); - } -} diff --git a/crates/pecos-core/src/pauli.rs b/crates/pecos-core/src/pauli.rs index e18f21402..87050ef04 100644 --- a/crates/pecos-core/src/pauli.rs +++ b/crates/pecos-core/src/pauli.rs @@ -13,9 +13,18 @@ pub mod algebra; pub mod constructors; +// Re-export constructors at the `pauli` level so `use pecos_core::pauli::*` works. +pub use constructors::{I, X, Xs, Y, Ys, Z, Zs}; + +// Re-export key types so `use pecos_core::pauli::*` gives the full Pauli toolkit. +pub use pauli_string::PauliString; + #[allow(clippy::module_name_repetitions)] pub mod pauli_bitmap; +#[allow(clippy::module_name_repetitions)] +pub mod pauli_bitmask; + #[allow(clippy::module_name_repetitions)] pub mod pauli_sparse; diff --git a/crates/pecos-core/src/pauli/algebra.rs b/crates/pecos-core/src/pauli/algebra.rs index b859bf636..e038b56b7 100644 --- a/crates/pecos-core/src/pauli/algebra.rs +++ b/crates/pecos-core/src/pauli/algebra.rs @@ -45,6 +45,7 @@ //! let ps = -PauliString::x(0); // -X //! ``` +use crate::qubit_support::overlapping_qubits; use crate::{Pauli, PauliString, Phase, QuarterPhase, QubitId}; use std::ops::{BitAnd, Mul, Neg}; @@ -80,10 +81,16 @@ impl BitAnd for PauliString { type Output = PauliString; fn bitand(self, rhs: PauliString) -> PauliString { + let overlap = overlapping_qubits(self.qubits(), rhs.qubits()); + assert!( + overlap.is_empty(), + "tensor product requires disjoint Pauli support; overlapping qubits: {overlap:?}" + ); + // Combine phases let new_phase = self.phase().multiply(&rhs.phase()); - // Combine paulis (assuming no overlap - tensor product) + // Combine paulis. let mut paulis: Vec<(Pauli, QubitId)> = self.iter_pairs().collect(); paulis.extend(rhs.iter_pairs()); @@ -289,6 +296,12 @@ mod tests { assert_eq!(ps.weight(), 2); } + #[test] + #[should_panic(expected = "tensor product requires disjoint Pauli support")] + fn test_tensor_product_rejects_overlapping_qubits() { + let _ = PauliString::x(0) & PauliString::z(0); + } + #[test] fn test_triple_tensor() { let ps = PauliString::x(0) & PauliString::y(2) & PauliString::z(5); diff --git a/crates/pecos-core/src/pauli/constructors.rs b/crates/pecos-core/src/pauli/constructors.rs index fc67d9a76..507c509d9 100644 --- a/crates/pecos-core/src/pauli/constructors.rs +++ b/crates/pecos-core/src/pauli/constructors.rs @@ -18,7 +18,7 @@ //! # Examples //! //! ``` -//! use pecos_core::pauli::constructors::*; +//! use pecos_core::pauli::*; //! use pecos_core::PauliOperator; //! //! // Single-qubit Paulis @@ -88,7 +88,7 @@ impl QubitArgs for std::ops::RangeInclusive { /// # Examples /// /// ``` -/// use pecos_core::pauli::constructors::X; +/// use pecos_core::pauli::X; /// use pecos_core::{Pauli, PauliOperator}; /// /// let p = X(0); @@ -131,7 +131,7 @@ pub fn I() -> PauliString { /// # Examples /// /// ``` -/// use pecos_core::pauli::constructors::Xs; +/// use pecos_core::pauli::Xs; /// use pecos_core::PauliOperator; /// /// let p = Xs([0, 1, 2]); @@ -166,7 +166,7 @@ pub fn Ys(qubits: impl QubitArgs) -> PauliString { /// # Examples /// /// ``` -/// use pecos_core::pauli::constructors::Zs; +/// use pecos_core::pauli::Zs; /// use pecos_core::PauliOperator; /// /// let stab = Zs([0, 1]); diff --git a/crates/pecos-core/src/pauli/pauli_bitmask.rs b/crates/pecos-core/src/pauli/pauli_bitmask.rs new file mode 100644 index 000000000..a7beef474 --- /dev/null +++ b/crates/pecos-core/src/pauli/pauli_bitmask.rs @@ -0,0 +1,1426 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Compact Pauli operator using bitmasks with Clifford conjugation. +//! +//! Provides allocation-free Pauli arithmetic and Clifford conjugation +//! for use in error propagation, fault analysis, and EEG algorithms. +//! +//! The default `PauliBitmask` uses `u128` (up to 128 qubits). For +//! larger qubit counts, use `PauliBitmaskVec` which heap-allocates. + +use smallvec::SmallVec; +use std::fmt; + +/// Trait for bitmask storage backends. +/// +/// Enables `PauliBitmaskGeneric` to work with different widths: +/// `u64` (64 qubits), `u128` (128 qubits), or `Vec` (unlimited). +pub trait BitmaskStorage: Clone + PartialEq + Eq + std::hash::Hash + Default + fmt::Debug { + fn zero() -> Self; + fn set_bit(&mut self, bit: usize); + fn clear_bit(&mut self, bit: usize); + fn get_bit(&self, bit: usize) -> bool; + fn xor_assign(&mut self, other: &Self); + fn xor_bit(&mut self, bit: usize); + fn and_count_ones_xor(&self, other_z: &Self, self_z: &Self, other_x: &Self) -> u32; + fn is_zero(&self) -> bool; + fn or_count_ones(&self, other: &Self) -> u32; + fn highest_set_bit(&self) -> Option; +} + +impl BitmaskStorage for u128 { + fn zero() -> Self { + 0 + } + fn set_bit(&mut self, bit: usize) { + *self |= 1u128 << bit; + } + fn clear_bit(&mut self, bit: usize) { + *self &= !(1u128 << bit); + } + fn get_bit(&self, bit: usize) -> bool { + *self & (1u128 << bit) != 0 + } + fn xor_assign(&mut self, other: &Self) { + *self ^= other; + } + fn xor_bit(&mut self, bit: usize) { + *self ^= 1u128 << bit; + } + fn and_count_ones_xor(&self, other_z: &Self, self_z: &Self, other_x: &Self) -> u32 { + ((*self & other_z) ^ (self_z & other_x)).count_ones() + } + fn is_zero(&self) -> bool { + *self == 0 + } + fn or_count_ones(&self, other: &Self) -> u32 { + (*self | other).count_ones() + } + fn highest_set_bit(&self) -> Option { + if *self == 0 { + None + } else { + Some(127 - self.leading_zeros() as usize) + } + } +} + +impl BitmaskStorage for Vec { + fn zero() -> Self { + Vec::new() + } + fn set_bit(&mut self, bit: usize) { + let word = bit / 64; + if word >= self.len() { + self.resize(word + 1, 0); + } + self[word] |= 1u64 << (bit % 64); + } + fn clear_bit(&mut self, bit: usize) { + let word = bit / 64; + if word < self.len() { + self[word] &= !(1u64 << (bit % 64)); + } + } + fn get_bit(&self, bit: usize) -> bool { + let word = bit / 64; + word < self.len() && self[word] & (1u64 << (bit % 64)) != 0 + } + fn xor_assign(&mut self, other: &Self) { + if self.len() < other.len() { + self.resize(other.len(), 0); + } + for (a, b) in self.iter_mut().zip(other.iter()) { + *a ^= b; + } + } + fn xor_bit(&mut self, bit: usize) { + let word = bit / 64; + if word >= self.len() { + self.resize(word + 1, 0); + } + self[word] ^= 1u64 << (bit % 64); + } + fn and_count_ones_xor(&self, other_z: &Self, self_z: &Self, other_x: &Self) -> u32 { + let max = self + .len() + .max(other_z.len()) + .max(self_z.len()) + .max(other_x.len()); + let mut count = 0u32; + for i in 0..max { + let sx = self.get(i).copied().unwrap_or(0); + let oz = other_z.get(i).copied().unwrap_or(0); + let sz = self_z.get(i).copied().unwrap_or(0); + let ox = other_x.get(i).copied().unwrap_or(0); + count += ((sx & oz) ^ (sz & ox)).count_ones(); + } + count + } + fn is_zero(&self) -> bool { + self.iter().all(|&w| w == 0) + } + fn or_count_ones(&self, other: &Self) -> u32 { + let max = self.len().max(other.len()); + let mut count = 0u32; + for i in 0..max { + let a = self.get(i).copied().unwrap_or(0); + let b = other.get(i).copied().unwrap_or(0); + count += (a | b).count_ones(); + } + count + } + fn highest_set_bit(&self) -> Option { + for (i, &w) in self.iter().enumerate().rev() { + if w != 0 { + return Some(i * 64 + 63 - w.leading_zeros() as usize); + } + } + None + } +} + +/// `SmallVec<[u64; 8]>` backend: 512 bits inline (covers d≤9 surface codes), +/// spills to heap for larger circuits. Zero allocation for typical QEC. +impl BitmaskStorage for SmallVec<[u64; 8]> { + fn zero() -> Self { + SmallVec::new() + } + fn set_bit(&mut self, bit: usize) { + let word = bit / 64; + if word >= self.len() { + self.resize(word + 1, 0); + } + self[word] |= 1u64 << (bit % 64); + } + fn clear_bit(&mut self, bit: usize) { + let word = bit / 64; + if word < self.len() { + self[word] &= !(1u64 << (bit % 64)); + } + } + fn get_bit(&self, bit: usize) -> bool { + let word = bit / 64; + word < self.len() && self[word] & (1u64 << (bit % 64)) != 0 + } + fn xor_assign(&mut self, other: &Self) { + if self.len() < other.len() { + self.resize(other.len(), 0); + } + for (a, b) in self.iter_mut().zip(other.iter()) { + *a ^= b; + } + } + fn xor_bit(&mut self, bit: usize) { + let word = bit / 64; + if word >= self.len() { + self.resize(word + 1, 0); + } + self[word] ^= 1u64 << (bit % 64); + } + fn and_count_ones_xor(&self, other_z: &Self, self_z: &Self, other_x: &Self) -> u32 { + let max = self + .len() + .max(other_z.len()) + .max(self_z.len()) + .max(other_x.len()); + let mut count = 0u32; + for i in 0..max { + let sx = self.get(i).copied().unwrap_or(0); + let oz = other_z.get(i).copied().unwrap_or(0); + let sz = self_z.get(i).copied().unwrap_or(0); + let ox = other_x.get(i).copied().unwrap_or(0); + count += ((sx & oz) ^ (sz & ox)).count_ones(); + } + count + } + fn is_zero(&self) -> bool { + self.iter().all(|&w| w == 0) + } + fn or_count_ones(&self, other: &Self) -> u32 { + let max = self.len().max(other.len()); + let mut count = 0u32; + for i in 0..max { + let a = self.get(i).copied().unwrap_or(0); + let b = other.get(i).copied().unwrap_or(0); + count += (a | b).count_ones(); + } + count + } + fn highest_set_bit(&self) -> Option { + for (i, &w) in self.iter().enumerate().rev() { + if w != 0 { + return Some(i * 64 + 63 - w.leading_zeros() as usize); + } + } + None + } +} + +/// N-qubit Pauli operator in symplectic binary representation. +/// +/// Phase is NOT tracked internally — the caller tracks signs from +/// multiplication and conjugation separately. +/// +/// Type parameter `B` selects the bitmask backend: +/// - `u128` (default): up to 128 qubits, stack-allocated, `Copy` +/// - `Vec`: unlimited qubits, heap-allocated +#[derive(Clone, Default)] +pub struct PauliBitmaskGeneric { + pub x_bits: B, + pub z_bits: B, +} + +// --- PartialEq, Eq, Hash for u128 backend --- + +impl PartialEq for PauliBitmaskGeneric { + fn eq(&self, other: &Self) -> bool { + self.x_bits == other.x_bits && self.z_bits == other.z_bits + } +} + +impl Eq for PauliBitmaskGeneric {} + +impl std::hash::Hash for PauliBitmaskGeneric { + fn hash(&self, state: &mut H) { + self.x_bits.hash(state); + self.z_bits.hash(state); + } +} + +// --- PartialEq, Eq, Hash for Vec backend --- +// Trailing zero words are ignored so that Vecs of different lengths +// representing the same logical value compare equal and hash identically. + +impl PartialEq for PauliBitmaskGeneric> { + fn eq(&self, other: &Self) -> bool { + vecs_eq_ignoring_trailing_zeros(&self.x_bits, &other.x_bits) + && vecs_eq_ignoring_trailing_zeros(&self.z_bits, &other.z_bits) + } +} + +impl Eq for PauliBitmaskGeneric> {} + +impl std::hash::Hash for PauliBitmaskGeneric> { + fn hash(&self, state: &mut H) { + hash_vec_ignoring_trailing_zeros(&self.x_bits, state); + hash_vec_ignoring_trailing_zeros(&self.z_bits, state); + } +} + +fn vecs_eq_ignoring_trailing_zeros(a: &[u64], b: &[u64]) -> bool { + let max = a.len().max(b.len()); + for i in 0..max { + let aw = a.get(i).copied().unwrap_or(0); + let bw = b.get(i).copied().unwrap_or(0); + if aw != bw { + return false; + } + } + true +} + +fn hash_vec_ignoring_trailing_zeros(v: &[u64], state: &mut H) { + use std::hash::Hash; + // Find the last non-zero word and hash only up to that point. + let effective_len = v.iter().rposition(|&w| w != 0).map_or(0, |i| i + 1); + effective_len.hash(state); + for &w in &v[..effective_len] { + w.hash(state); + } +} + +/// Fixed-size Pauli bitmask for up to 128 qubits (stack-allocated, Copy). +pub type PauliBitmask = PauliBitmaskGeneric; + +/// Dynamically-sized Pauli bitmask for unlimited qubits (heap-allocated). +pub type PauliBitmaskVec = PauliBitmaskGeneric>; + +/// SmallVec-backed Pauli bitmask: 512 bits inline (d≤9 surface codes), +/// spills to heap only for larger circuits. Best of both worlds. +pub type PauliBitmaskSmall = PauliBitmaskGeneric>; + +// --- PartialEq, Eq, Hash, Ord for SmallVec backend --- + +impl PartialEq for PauliBitmaskSmall { + fn eq(&self, other: &Self) -> bool { + vecs_eq_ignoring_trailing_zeros(&self.x_bits, &other.x_bits) + && vecs_eq_ignoring_trailing_zeros(&self.z_bits, &other.z_bits) + } +} + +impl Eq for PauliBitmaskSmall {} + +impl std::hash::Hash for PauliBitmaskSmall { + fn hash(&self, state: &mut H) { + hash_vec_ignoring_trailing_zeros(&self.x_bits, state); + hash_vec_ignoring_trailing_zeros(&self.z_bits, state); + } +} + +impl PartialOrd for PauliBitmaskSmall { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for PauliBitmaskSmall { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + let max_len = self + .x_bits + .len() + .max(other.x_bits.len()) + .max(self.z_bits.len()) + .max(other.z_bits.len()); + for i in (0..max_len).rev() { + let sx = self.x_bits.get(i).copied().unwrap_or(0); + let ox = other.x_bits.get(i).copied().unwrap_or(0); + match sx.cmp(&ox) { + std::cmp::Ordering::Equal => {} + ord => return ord, + } + } + for i in (0..max_len).rev() { + let sz = self.z_bits.get(i).copied().unwrap_or(0); + let oz = other.z_bits.get(i).copied().unwrap_or(0); + match sz.cmp(&oz) { + std::cmp::Ordering::Equal => {} + ord => return ord, + } + } + std::cmp::Ordering::Equal + } +} + +// Copy only for fixed-size backends +impl Copy for PauliBitmask {} +impl PartialOrd for PauliBitmask { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for PauliBitmask { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.x_bits + .cmp(&other.x_bits) + .then(self.z_bits.cmp(&other.z_bits)) + } +} + +impl PartialOrd for PauliBitmaskVec { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} +impl Ord for PauliBitmaskVec { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + // Lexicographic comparison of the word vectors (most-significant word first) + let max_len = self + .x_bits + .len() + .max(other.x_bits.len()) + .max(self.z_bits.len()) + .max(other.z_bits.len()); + for i in (0..max_len).rev() { + let sx = self.x_bits.get(i).copied().unwrap_or(0); + let ox = other.x_bits.get(i).copied().unwrap_or(0); + match sx.cmp(&ox) { + std::cmp::Ordering::Equal => {} + ord => return ord, + } + } + for i in (0..max_len).rev() { + let sz = self.z_bits.get(i).copied().unwrap_or(0); + let oz = other.z_bits.get(i).copied().unwrap_or(0); + match sz.cmp(&oz) { + std::cmp::Ordering::Equal => {} + ord => return ord, + } + } + std::cmp::Ordering::Equal + } +} + +impl PauliBitmaskGeneric { + /// Single-qubit X on qubit q. + #[must_use] + pub fn x(q: usize) -> Self { + let mut x = B::zero(); + x.set_bit(q); + Self { + x_bits: x, + z_bits: B::zero(), + } + } + + /// Single-qubit Z on qubit q. + #[must_use] + pub fn z(q: usize) -> Self { + let mut z = B::zero(); + z.set_bit(q); + Self { + x_bits: B::zero(), + z_bits: z, + } + } + + /// Single-qubit Y on qubit q. + #[must_use] + pub fn y(q: usize) -> Self { + let mut x = B::zero(); + let mut z = B::zero(); + x.set_bit(q); + z.set_bit(q); + Self { + x_bits: x, + z_bits: z, + } + } + + /// Product of two Pauli labels (XOR of symplectic vectors, phase ignored). + #[must_use] + pub fn multiply(&self, other: &Self) -> Self { + let mut x = self.x_bits.clone(); + x.xor_assign(&other.x_bits); + let mut z = self.z_bits.clone(); + z.xor_assign(&other.z_bits); + Self { + x_bits: x, + z_bits: z, + } + } + + /// Product of two Paulis with phase tracking. + /// + /// Returns `(product, phase_exponent)` where the full product is i^phase · product. + /// Phase exponent is in 0..4. + #[must_use] + pub fn multiply_with_phase(&self, other: &Self) -> (Self, u8) { + // Per-qubit phase from Pauli multiplication. + // Pauli types: I=0, X=1, Z=2, Y=3 (encoding: type = x + 2*z) + // Phase lookup: A*B = i^{phase[A][B]} * C + // I X Z Y + // 0 0 0 0 (I * anything) + // 0 0 3 1 (X * I,X,Z,Y) + // 0 1 0 3 (Z * I,X,Z,Y) + // 0 3 1 0 (Y * I,X,Z,Y) + const PHASE_TABLE: [[u8; 4]; 4] = [ + [0, 0, 0, 0], // I + [0, 0, 3, 1], // X + [0, 1, 0, 3], // Z + [0, 3, 1, 0], // Y + ]; + + let product = self.multiply(other); + let mut total_phase = 0u32; + let max_q = [ + self.x_bits.highest_set_bit(), + other.x_bits.highest_set_bit(), + self.z_bits.highest_set_bit(), + other.z_bits.highest_set_bit(), + ] + .into_iter() + .flatten() + .max() + .map_or(0, |q| q + 1); + + for q in 0..max_q { + let xa = usize::from(self.x_bits.get_bit(q)); + let za = usize::from(self.z_bits.get_bit(q)); + let xb = usize::from(other.x_bits.get_bit(q)); + let zb = usize::from(other.z_bits.get_bit(q)); + let type_a = xa + 2 * za; // I=0, X=1, Z=2, Y=3 + let type_b = xb + 2 * zb; + total_phase += u32::from(PHASE_TABLE[type_a][type_b]); + } + + (product, (total_phase % 4) as u8) + } + + /// True if the two Paulis commute (symplectic inner product = 0 mod 2). + #[must_use] + pub fn commutes_with(&self, other: &Self) -> bool { + self.x_bits + .and_count_ones_xor(&other.z_bits, &self.z_bits, &other.x_bits) + .is_multiple_of(2) + } + + #[must_use] + pub fn is_identity(&self) -> bool { + self.x_bits.is_zero() && self.z_bits.is_zero() + } + + /// Number of non-identity single-qubit factors. + #[must_use] + pub fn weight(&self) -> u32 { + self.x_bits.or_count_ones(&self.z_bits) + } + + #[must_use] + pub fn has_x(&self, q: usize) -> bool { + self.x_bits.get_bit(q) + } + + #[must_use] + pub fn has_z(&self, q: usize) -> bool { + self.z_bits.get_bit(q) + } +} + +impl PauliBitmask { + pub const IDENTITY: Self = Self { + x_bits: 0, + z_bits: 0, + }; +} + +impl PauliBitmaskGeneric { + /// Identity Pauli (all qubits I). + #[must_use] + pub fn identity() -> Self { + Self { + x_bits: B::zero(), + z_bits: B::zero(), + } + } +} + +/// Convert from u128 (fixed-size) to Vec (unlimited) backend. +impl From for PauliBitmaskVec { + fn from(p: PauliBitmask) -> Self { + let x_lo = u64::try_from(p.x_bits & u128::from(u64::MAX)).expect("masked low word fits"); + let x_hi = u64::try_from(p.x_bits >> 64).expect("shifted high word fits"); + let z_lo = u64::try_from(p.z_bits & u128::from(u64::MAX)).expect("masked low word fits"); + let z_hi = u64::try_from(p.z_bits >> 64).expect("shifted high word fits"); + Self { + x_bits: if x_hi != 0 { + vec![x_lo, x_hi] + } else if x_lo != 0 { + vec![x_lo] + } else { + vec![] + }, + z_bits: if z_hi != 0 { + vec![z_lo, z_hi] + } else if z_lo != 0 { + vec![z_lo] + } else { + vec![] + }, + } + } +} + +impl From for PauliBitmaskSmall { + fn from(p: PauliBitmask) -> Self { + let x_lo = u64::try_from(p.x_bits & u128::from(u64::MAX)).expect("masked low word fits"); + let x_hi = u64::try_from(p.x_bits >> 64).expect("shifted high word fits"); + let z_lo = u64::try_from(p.z_bits & u128::from(u64::MAX)).expect("masked low word fits"); + let z_hi = u64::try_from(p.z_bits >> 64).expect("shifted high word fits"); + let mut x = SmallVec::new(); + if x_hi != 0 { + x.push(x_lo); + x.push(x_hi); + } else if x_lo != 0 { + x.push(x_lo); + } + let mut z = SmallVec::new(); + if z_hi != 0 { + z.push(z_lo); + z.push(z_hi); + } else if z_lo != 0 { + z.push(z_lo); + } + Self { + x_bits: x, + z_bits: z, + } + } +} + +impl fmt::Debug for PauliBitmaskGeneric { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.is_identity() { + return write!(f, "I"); + } + let max_q = match self.x_bits.highest_set_bit() { + Some(a) => match self.z_bits.highest_set_bit() { + Some(b) => a.max(b) + 1, + None => a + 1, + }, + None => match self.z_bits.highest_set_bit() { + Some(b) => b + 1, + None => return write!(f, "I"), + }, + }; + for q in 0..max_q { + match (self.has_x(q), self.has_z(q)) { + (false, false) => write!(f, "I")?, + (true, false) => write!(f, "X")?, + (false, true) => write!(f, "Z")?, + (true, true) => write!(f, "Y")?, + } + } + Ok(()) + } +} + +// ============================================================ +// Clifford conjugation: U†PU = sign * P' +// ============================================================ + +/// Result of Clifford conjugation U†PU. +#[derive(Clone, Debug)] +pub struct Conjugated { + pub label: PauliBitmaskGeneric, + /// True if the sign is negative (U†PU = -P'). + pub sign_negative: bool, +} + +impl Copy for Conjugated {} + +/// Hadamard on qubit q: X↔Z, Y→-Y. +#[must_use] +pub fn conjugate_h(p: &PauliBitmaskGeneric, q: usize) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_h_in_place(&mut label, q); + Conjugated { + label, + sign_negative, + } +} + +/// In-place Hadamard conjugation on qubit q. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_h_in_place(p: &mut PauliBitmaskGeneric, q: usize) -> bool { + let has_x = p.x_bits.get_bit(q); + let has_z = p.z_bits.get_bit(q); + if has_x != has_z { + p.x_bits.xor_bit(q); + p.z_bits.xor_bit(q); + } + has_x && has_z +} + +/// SZ gate on qubit q: X→Y, Y→-X, Z→Z. +#[must_use] +pub fn conjugate_sz(p: &PauliBitmaskGeneric, q: usize) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_sz_in_place(&mut label, q); + Conjugated { + label, + sign_negative, + } +} + +/// In-place SZ conjugation on qubit q. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_sz_in_place(p: &mut PauliBitmaskGeneric, q: usize) -> bool { + if !p.x_bits.get_bit(q) { + return false; + } + let was_y = p.z_bits.get_bit(q); + p.z_bits.xor_bit(q); + was_y +} + +/// `SZdg` gate on qubit q: X→-Y, Y→X, Z→Z. +#[must_use] +pub fn conjugate_szdg(p: &PauliBitmaskGeneric, q: usize) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_szdg_in_place(&mut label, q); + Conjugated { + label, + sign_negative, + } +} + +/// In-place `SZdg` conjugation on qubit q. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_szdg_in_place( + p: &mut PauliBitmaskGeneric, + q: usize, +) -> bool { + if !p.x_bits.get_bit(q) { + return false; + } + let was_y = p.z_bits.get_bit(q); + p.z_bits.xor_bit(q); + !was_y +} + +/// CX (CNOT) with control c, target t: XI→XX, IZ→ZZ. +/// +/// The sign comes from Pauli multiplication phases when the control's +/// Pauli multiplies Z (from target Z spreading) and the target's Pauli +/// multiplies X (from control X spreading): +/// `phase_c` = phase(Pc · Z) if target has Z, else 0 +/// `phase_t` = phase(X · Pt) if control has X, else 0 +/// `sign_negative` = (`phase_c` + `phase_t`) % 4 == 2 +#[must_use] +pub fn conjugate_cx( + p: &PauliBitmaskGeneric, + c: usize, + t: usize, +) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_cx_in_place(&mut label, c, t); + Conjugated { + label, + sign_negative, + } +} + +/// In-place CX conjugation with control c and target t. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_cx_in_place( + p: &mut PauliBitmaskGeneric, + c: usize, + t: usize, +) -> bool { + const PHASE: [[u8; 4]; 4] = [ + [0, 0, 0, 0], // I·{I,X,Z,Y} + [0, 0, 3, 1], // X·{I,X,Z,Y} + [0, 1, 0, 3], // Z·{I,X,Z,Y} + [0, 3, 1, 0], // Y·{I,X,Z,Y} + ]; + + let cx = p.x_bits.get_bit(c); + let cz = p.z_bits.get_bit(c); + let tx = p.x_bits.get_bit(t); + let tz = p.z_bits.get_bit(t); + if cx { + p.x_bits.xor_bit(t); + } + if tz { + p.z_bits.xor_bit(c); + } + // Pauli type encoding: I=0, X=1, Z=2, Y=3 (x + 2*z) + // Phase from Pauli multiplication table: + // Pc·Z at control (if tz), X·Pt at target (if cx) + let pc = u8::from(cx) + 2 * u8::from(cz); + let pt = u8::from(tx) + 2 * u8::from(tz); + let phase_c = if tz { PHASE[pc as usize][2] } else { 0 }; + let phase_t = if cx { PHASE[1][pt as usize] } else { 0 }; + (phase_c + phase_t) % 4 == 2 +} + +/// CZ on qubits a, b: XI→XZ, IX→ZX, ZI→ZI, IZ→IZ. +#[must_use] +pub fn conjugate_cz( + p: &PauliBitmaskGeneric, + a: usize, + b: usize, +) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_cz_in_place(&mut label, a, b); + Conjugated { + label, + sign_negative, + } +} + +/// In-place CZ conjugation on qubits a and b. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_cz_in_place( + p: &mut PauliBitmaskGeneric, + a: usize, + b: usize, +) -> bool { + let ax = p.x_bits.get_bit(a); + let az = p.z_bits.get_bit(a); + let bx = p.x_bits.get_bit(b); + let bz = p.z_bits.get_bit(b); + if bx { + p.z_bits.xor_bit(a); + } + if ax { + p.z_bits.xor_bit(b); + } + ax && bx && (az != bz) +} + +/// Pauli X gate on qubit q: Z→-Z, Y→-Y. +#[must_use] +pub fn conjugate_x(p: &PauliBitmaskGeneric, q: usize) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_x_in_place(&mut label, q); + Conjugated { + label, + sign_negative, + } +} + +/// In-place Pauli X conjugation on qubit q. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_x_in_place(p: &mut PauliBitmaskGeneric, q: usize) -> bool { + p.z_bits.get_bit(q) +} + +/// Pauli Y gate on qubit q: X→-X, Z→-Z. +#[must_use] +pub fn conjugate_y(p: &PauliBitmaskGeneric, q: usize) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_y_in_place(&mut label, q); + Conjugated { + label, + sign_negative, + } +} + +/// In-place Pauli Y conjugation on qubit q. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_y_in_place(p: &mut PauliBitmaskGeneric, q: usize) -> bool { + p.x_bits.get_bit(q) != p.z_bits.get_bit(q) +} + +/// Pauli Z gate on qubit q: X→-X, Y→-Y. +#[must_use] +pub fn conjugate_z(p: &PauliBitmaskGeneric, q: usize) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_z_in_place(&mut label, q); + Conjugated { + label, + sign_negative, + } +} + +/// In-place Pauli Z conjugation on qubit q. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_z_in_place(p: &mut PauliBitmaskGeneric, q: usize) -> bool { + p.x_bits.get_bit(q) +} + +/// SWAP on qubits a, b: exchanges the Pauli at both sites. +#[must_use] +pub fn conjugate_swap( + p: &PauliBitmaskGeneric, + a: usize, + b: usize, +) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_swap_in_place(&mut label, a, b); + Conjugated { + label, + sign_negative, + } +} + +/// In-place SWAP conjugation on qubits a and b. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_swap_in_place( + p: &mut PauliBitmaskGeneric, + a: usize, + b: usize, +) -> bool { + let ax = p.x_bits.get_bit(a); + let az = p.z_bits.get_bit(a); + let bx = p.x_bits.get_bit(b); + let bz = p.z_bits.get_bit(b); + // Clear both positions + p.x_bits.clear_bit(a); + p.x_bits.clear_bit(b); + if az { + p.z_bits.clear_bit(a); + } + if bz { + p.z_bits.clear_bit(b); + } + // Set swapped + if bx { + p.x_bits.set_bit(a); + } + if ax { + p.x_bits.set_bit(b); + } + if bz { + p.z_bits.set_bit(a); + } + if az { + p.z_bits.set_bit(b); + } + false +} + +/// SX gate on qubit q: X→X, Z→-Y, Y→Z. +#[must_use] +pub fn conjugate_sx(p: &PauliBitmaskGeneric, q: usize) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_sx_in_place(&mut label, q); + Conjugated { + label, + sign_negative, + } +} + +/// In-place SX conjugation on qubit q. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_sx_in_place(p: &mut PauliBitmaskGeneric, q: usize) -> bool { + let xq = p.x_bits.get_bit(q); + let zq = p.z_bits.get_bit(q); + if zq { + p.x_bits.xor_bit(q); + } + !xq && zq +} + +/// `SXdg` gate on qubit q: X→X, Z→Y, Y→-Z. +#[must_use] +pub fn conjugate_sxdg(p: &PauliBitmaskGeneric, q: usize) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_sxdg_in_place(&mut label, q); + Conjugated { + label, + sign_negative, + } +} + +/// In-place `SXdg` conjugation on qubit q. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_sxdg_in_place( + p: &mut PauliBitmaskGeneric, + q: usize, +) -> bool { + let xq = p.x_bits.get_bit(q); + let zq = p.z_bits.get_bit(q); + if zq { + p.x_bits.xor_bit(q); + } + xq && zq +} + +/// SY gate on qubit q: X→-Z, Y→Y, Z→X. +#[must_use] +pub fn conjugate_sy(p: &PauliBitmaskGeneric, q: usize) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_sy_in_place(&mut label, q); + Conjugated { + label, + sign_negative, + } +} + +/// In-place SY conjugation on qubit q. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_sy_in_place(p: &mut PauliBitmaskGeneric, q: usize) -> bool { + let xq = p.x_bits.get_bit(q); + let zq = p.z_bits.get_bit(q); + if xq != zq { + p.x_bits.xor_bit(q); + p.z_bits.xor_bit(q); + } + xq && !zq +} + +/// `SYdg` gate on qubit q: X→Z, Y→Y, Z→-X. +#[must_use] +pub fn conjugate_sydg(p: &PauliBitmaskGeneric, q: usize) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_sydg_in_place(&mut label, q); + Conjugated { + label, + sign_negative, + } +} + +/// In-place `SYdg` conjugation on qubit q. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_sydg_in_place( + p: &mut PauliBitmaskGeneric, + q: usize, +) -> bool { + let xq = p.x_bits.get_bit(q); + let zq = p.z_bits.get_bit(q); + if xq != zq { + p.x_bits.xor_bit(q); + p.z_bits.xor_bit(q); + } + !xq && zq +} + +/// CY (controlled-Y) with control c, target t. +/// +/// Decomposed as CY = (I⊗SZ) · CX · (I⊗SZdg), so conjugation is: +/// 1. conjugate by `SZdg` on target +/// 2. conjugate by CX +/// 3. conjugate by SZ on target +#[must_use] +pub fn conjugate_cy( + p: &PauliBitmaskGeneric, + c: usize, + t: usize, +) -> Conjugated { + let mut label = p.clone(); + let sign_negative = conjugate_cy_in_place(&mut label, c, t); + Conjugated { + label, + sign_negative, + } +} + +/// In-place CY conjugation with control c and target t. +/// +/// Returns `true` when the conjugation contributes a negative sign. +#[must_use] +pub fn conjugate_cy_in_place( + p: &mut PauliBitmaskGeneric, + c: usize, + t: usize, +) -> bool { + conjugate_szdg_in_place(p, t) ^ conjugate_cx_in_place(p, c, t) ^ conjugate_sz_in_place(p, t) +} + +#[cfg(test)] +mod tests { + use super::*; + + // --- PauliBitmask basics --- + + #[test] + fn test_commutation() { + assert!(!PauliBitmask::x(0).commutes_with(&PauliBitmask::z(0))); + assert!(PauliBitmask::x(0).commutes_with(&PauliBitmask::x(1))); + assert!(!PauliBitmask::x(0).commutes_with(&PauliBitmask::y(0))); + + let a = PauliBitmask { + x_bits: 1, + z_bits: 2, + }; + let b = PauliBitmask { + x_bits: 2, + z_bits: 1, + }; + assert!(a.commutes_with(&b)); + } + + #[test] + fn test_multiply() { + assert_eq!( + PauliBitmask::x(0).multiply(&PauliBitmask::z(0)), + PauliBitmask::y(0) + ); + } + + #[test] + fn test_h() { + let r = conjugate_h(&PauliBitmask::x(0), 0); + assert_eq!(r.label, PauliBitmask::z(0)); + assert!(!r.sign_negative); + + let r = conjugate_h(&PauliBitmask::z(0), 0); + assert_eq!(r.label, PauliBitmask::x(0)); + assert!(!r.sign_negative); + + let r = conjugate_h(&PauliBitmask::y(0), 0); + assert_eq!(r.label, PauliBitmask::y(0)); + assert!(r.sign_negative); + } + + #[test] + fn test_sz() { + let r = conjugate_sz(&PauliBitmask::x(0), 0); + assert_eq!(r.label, PauliBitmask::y(0)); + assert!(!r.sign_negative); + + let r = conjugate_sz(&PauliBitmask::y(0), 0); + assert_eq!(r.label, PauliBitmask::x(0)); + assert!(r.sign_negative); + + let r = conjugate_sz(&PauliBitmask::z(0), 0); + assert_eq!(r.label, PauliBitmask::z(0)); + assert!(!r.sign_negative); + } + + #[test] + fn test_szdg() { + let r = conjugate_szdg(&PauliBitmask::x(0), 0); + assert_eq!(r.label, PauliBitmask::y(0)); + assert!(r.sign_negative); + + let r = conjugate_szdg(&PauliBitmask::y(0), 0); + assert_eq!(r.label, PauliBitmask::x(0)); + assert!(!r.sign_negative); + } + + #[test] + fn test_cx() { + let r = conjugate_cx(&PauliBitmask::x(0), 0, 1); + assert_eq!( + r.label, + PauliBitmask { + x_bits: 0b11, + z_bits: 0 + } + ); + assert!(!r.sign_negative); + + let r = conjugate_cx(&PauliBitmask::z(1), 0, 1); + assert_eq!( + r.label, + PauliBitmask { + x_bits: 0, + z_bits: 0b11 + } + ); + assert!(!r.sign_negative); + + let r = conjugate_cx(&PauliBitmask::x(1), 0, 1); + assert_eq!(r.label, PauliBitmask::x(1)); + assert!(!r.sign_negative); + } + + #[test] + fn test_cz() { + let r = conjugate_cz(&PauliBitmask::x(0), 0, 1); + assert_eq!( + r.label, + PauliBitmask { + x_bits: 1, + z_bits: 2 + } + ); + assert!(!r.sign_negative); + + let r = conjugate_cz(&PauliBitmask::z(0), 0, 1); + assert_eq!(r.label, PauliBitmask::z(0)); + assert!(!r.sign_negative); + } + + #[test] + fn test_swap() { + let r = conjugate_swap(&PauliBitmask::x(0), 0, 1); + assert_eq!(r.label, PauliBitmask::x(1)); + assert!(!r.sign_negative); + } + + #[test] + fn test_h_involution() { + for p in [PauliBitmask::x(0), PauliBitmask::z(0), PauliBitmask::y(0)] { + let r1 = conjugate_h(&p, 0); + let r2 = conjugate_h(&r1.label, 0); + assert_eq!(r2.label, p); + assert!(!(r1.sign_negative ^ r2.sign_negative)); + } + } + + #[test] + fn test_sz_fourth_power() { + let p = PauliBitmask::x(0); + let mut label = p; + let mut sign = false; + for _ in 0..4 { + let r = conjugate_sz(&label, 0); + label = r.label; + sign ^= r.sign_negative; + } + assert_eq!(label, p); + assert!(!sign); + } + + #[test] + fn test_sx() { + // X→X (no sign) + let r = conjugate_sx(&PauliBitmask::x(0), 0); + assert_eq!(r.label, PauliBitmask::x(0)); + assert!(!r.sign_negative); + + // Z→-Y + let r = conjugate_sx(&PauliBitmask::z(0), 0); + assert_eq!(r.label, PauliBitmask::y(0)); + assert!(r.sign_negative); + + // Y→Z (no sign) + let r = conjugate_sx(&PauliBitmask::y(0), 0); + assert_eq!(r.label, PauliBitmask::z(0)); + assert!(!r.sign_negative); + } + + #[test] + fn test_sxdg() { + // X→X + let r = conjugate_sxdg(&PauliBitmask::x(0), 0); + assert_eq!(r.label, PauliBitmask::x(0)); + assert!(!r.sign_negative); + + // Z→Y + let r = conjugate_sxdg(&PauliBitmask::z(0), 0); + assert_eq!(r.label, PauliBitmask::y(0)); + assert!(!r.sign_negative); + + // Y→-Z + let r = conjugate_sxdg(&PauliBitmask::y(0), 0); + assert_eq!(r.label, PauliBitmask::z(0)); + assert!(r.sign_negative); + } + + #[test] + fn test_sy() { + // X→-Z + let r = conjugate_sy(&PauliBitmask::x(0), 0); + assert_eq!(r.label, PauliBitmask::z(0)); + assert!(r.sign_negative); + + // Z→X + let r = conjugate_sy(&PauliBitmask::z(0), 0); + assert_eq!(r.label, PauliBitmask::x(0)); + assert!(!r.sign_negative); + + // Y→Y + let r = conjugate_sy(&PauliBitmask::y(0), 0); + assert_eq!(r.label, PauliBitmask::y(0)); + assert!(!r.sign_negative); + } + + #[test] + fn test_sydg() { + // X→Z + let r = conjugate_sydg(&PauliBitmask::x(0), 0); + assert_eq!(r.label, PauliBitmask::z(0)); + assert!(!r.sign_negative); + + // Z→-X + let r = conjugate_sydg(&PauliBitmask::z(0), 0); + assert_eq!(r.label, PauliBitmask::x(0)); + assert!(r.sign_negative); + + // Y→Y + let r = conjugate_sydg(&PauliBitmask::y(0), 0); + assert_eq!(r.label, PauliBitmask::y(0)); + assert!(!r.sign_negative); + } + + #[test] + fn test_sx_fourth_power() { + let p = PauliBitmask::z(0); + let mut label = p; + let mut sign = false; + for _ in 0..4 { + let r = conjugate_sx(&label, 0); + label = r.label; + sign ^= r.sign_negative; + } + assert_eq!(label, p); + assert!(!sign); + } + + #[test] + fn test_sy_fourth_power() { + let p = PauliBitmask::x(0); + let mut label = p; + let mut sign = false; + for _ in 0..4 { + let r = conjugate_sy(&label, 0); + label = r.label; + sign ^= r.sign_negative; + } + assert_eq!(label, p); + assert!(!sign); + } + + #[test] + fn test_sx_sxdg_inverse() { + for p in [PauliBitmask::x(0), PauliBitmask::z(0), PauliBitmask::y(0)] { + let r1 = conjugate_sx(&p, 0); + let r2 = conjugate_sxdg(&r1.label, 0); + assert_eq!(r2.label, p); + assert!(!(r1.sign_negative ^ r2.sign_negative)); + } + } + + #[test] + fn test_sy_sydg_inverse() { + for p in [PauliBitmask::x(0), PauliBitmask::z(0), PauliBitmask::y(0)] { + let r1 = conjugate_sy(&p, 0); + let r2 = conjugate_sydg(&r1.label, 0); + assert_eq!(r2.label, p); + assert!(!(r1.sign_negative ^ r2.sign_negative)); + } + } + + #[test] + fn test_cy() { + // X_c → X_c Y_t + let r = conjugate_cy(&PauliBitmask::x(0), 0, 1); + assert_eq!( + r.label, + PauliBitmask { + x_bits: 0b11, + z_bits: 0b10 + } + ); + assert!(!r.sign_negative); + + // Z_c → Z_c + let r = conjugate_cy(&PauliBitmask::z(0), 0, 1); + assert_eq!(r.label, PauliBitmask::z(0)); + assert!(!r.sign_negative); + + // X_t → Z_c X_t + let r = conjugate_cy(&PauliBitmask::x(1), 0, 1); + assert_eq!( + r.label, + PauliBitmask { + x_bits: 0b10, + z_bits: 0b01 + } + ); + assert!(!r.sign_negative); + + // Z_t → Z_c Z_t + let r = conjugate_cy(&PauliBitmask::z(1), 0, 1); + assert_eq!( + r.label, + PauliBitmask { + x_bits: 0, + z_bits: 0b11 + } + ); + assert!(!r.sign_negative); + } + + #[test] + fn test_multiply_with_phase() { + // X * Z = -iY (phase = 3, i.e., i^3 = -i) + let (prod, phase) = PauliBitmask::x(0).multiply_with_phase(&PauliBitmask::z(0)); + assert_eq!(prod, PauliBitmask::y(0)); + assert_eq!(phase, 3); // i^3 = -i + + // Z * X = iY (phase = 1) + let (prod, phase) = PauliBitmask::z(0).multiply_with_phase(&PauliBitmask::x(0)); + assert_eq!(prod, PauliBitmask::y(0)); + assert_eq!(phase, 1); // i^1 = i + + // X * X = I (phase = 0) + let (prod, phase) = PauliBitmask::x(0).multiply_with_phase(&PauliBitmask::x(0)); + assert!(prod.is_identity()); + assert_eq!(phase, 0); + + // Y * Y = I (phase = 0) + let (prod, phase) = PauliBitmask::y(0).multiply_with_phase(&PauliBitmask::y(0)); + assert!(prod.is_identity()); + assert_eq!(phase, 0); + + // Multi-qubit: (X⊗Z) * (Z⊗X) = (XZ)⊗(ZX) = (-iY)⊗(iY) = (-i·i)(Y⊗Y) = Y⊗Y + let a = PauliBitmask { + x_bits: 0b01, + z_bits: 0b10, + }; // XZ + let b = PauliBitmask { + x_bits: 0b10, + z_bits: 0b01, + }; // ZX + let (prod, phase) = a.multiply_with_phase(&b); + assert_eq!( + prod, + PauliBitmask { + x_bits: 0b11, + z_bits: 0b11 + } + ); // YY + assert_eq!(phase, 0); // (-i)(i) = 1, phase = 3+1 = 4 mod 4 = 0 + } + + #[test] + fn test_cy_involution() { + // CY is hermitian (CY² = I), so double conjugation should be identity + for p in [ + PauliBitmask::x(0), + PauliBitmask::z(0), + PauliBitmask::x(1), + PauliBitmask::z(1), + ] { + let r1 = conjugate_cy(&p, 0, 1); + let r2 = conjugate_cy(&r1.label, 0, 1); + assert_eq!(r2.label, p); + assert!(!(r1.sign_negative ^ r2.sign_negative)); + } + } +} diff --git a/crates/pecos-core/src/qubit_support.rs b/crates/pecos-core/src/qubit_support.rs new file mode 100644 index 000000000..38dc41ce0 --- /dev/null +++ b/crates/pecos-core/src/qubit_support.rs @@ -0,0 +1,82 @@ +// 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. + +pub(crate) fn overlapping_qubits( + lhs: impl IntoIterator, + rhs: impl IntoIterator, +) -> Vec { + let mut lhs_qubits: Vec = lhs.into_iter().collect(); + lhs_qubits.sort_unstable(); + lhs_qubits.dedup(); + + let mut rhs_qubits: Vec = rhs.into_iter().collect(); + rhs_qubits.sort_unstable(); + rhs_qubits.dedup(); + + let mut overlap = Vec::new(); + let mut lhs_idx = 0; + let mut rhs_idx = 0; + while lhs_idx < lhs_qubits.len() && rhs_idx < rhs_qubits.len() { + match lhs_qubits[lhs_idx].cmp(&rhs_qubits[rhs_idx]) { + std::cmp::Ordering::Less => lhs_idx += 1, + std::cmp::Ordering::Greater => rhs_idx += 1, + std::cmp::Ordering::Equal => { + overlap.push(lhs_qubits[lhs_idx]); + lhs_idx += 1; + rhs_idx += 1; + } + } + } + overlap +} + +pub(crate) fn duplicate_qubits(qubits: impl IntoIterator) -> Vec { + let mut qubits: Vec = qubits.into_iter().collect(); + qubits.sort_unstable(); + + let mut duplicates = Vec::new(); + for window in qubits.windows(2) { + if window[0] == window[1] && duplicates.last() != Some(&window[0]) { + duplicates.push(window[0]); + } + } + duplicates +} + +pub(crate) fn assert_distinct_qubits(context: &str, qubits: impl IntoIterator) { + let duplicates = duplicate_qubits(qubits); + assert!( + duplicates.is_empty(), + "{context} requires distinct qubits; duplicated qubits: {duplicates:?}" + ); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn overlapping_qubits_returns_sorted_deduplicated_intersection() { + assert_eq!(overlapping_qubits([3, 1, 1, 2], [2, 2, 3, 4]), vec![2, 3]); + } + + #[test] + fn duplicate_qubits_returns_sorted_deduplicated_repeats() { + assert_eq!(duplicate_qubits([5, 1, 5, 2, 2, 2]), vec![2, 5]); + } + + #[test] + #[should_panic(expected = "CX requires distinct qubits; duplicated qubits: [0, 2]")] + fn assert_distinct_qubits_reports_context_and_duplicates() { + assert_distinct_qubits("CX", [2, 0, 1, 2, 0]); + } +} diff --git a/crates/pecos-core/src/unitary.rs b/crates/pecos-core/src/unitary.rs new file mode 100644 index 000000000..cf37279e9 --- /dev/null +++ b/crates/pecos-core/src/unitary.rs @@ -0,0 +1,29 @@ +// 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. + +//! Unitary gate algebra namespace. +//! +//! Re-exports the full unitary-level API so users can write: +//! +//! ``` +//! use pecos_core::unitary::*; +//! use pecos_core::Angle64; +//! +//! let circuit = T(1) * CX(0, 1) * H(0); +//! let layer = RZ(Angle64::HALF_TURN / 4, 0) & H(1); +//! ``` + +pub use crate::unitary_rep::{ + CCX, CX, CXs, CY, CYs, CZ, CZs, Commutativity, H, Hs, I, Is, ParseUnitaryRepError, PhaseValue, + QubitPairs, Qubits, RX, RXX, RXXs, RXs, RY, RYY, RYYs, RYs, RZ, RZZ, RZZs, RZs, RotationType, + SWAP, SWAPs, SX, SXs, SY, SYs, SZ, SZs, T, Ts, Unitary, UnitaryRep, X, Xs, Y, Ys, Z, Zs, phase, +}; diff --git a/crates/pecos-core/src/unitary_rep.rs b/crates/pecos-core/src/unitary_rep.rs index 95eac8d96..04e4917e5 100644 --- a/crates/pecos-core/src/unitary_rep.rs +++ b/crates/pecos-core/src/unitary_rep.rs @@ -29,7 +29,7 @@ //! # Examples //! //! ``` -//! use pecos_core::unitary_rep::*; +//! use pecos_core::unitary::*; //! use pecos_core::Angle64; //! //! // Build a circuit: H on q0, then CX(0,1), then T on q1 @@ -52,6 +52,7 @@ use crate::gate_type::GateType; use crate::pauli::PauliOperator; use crate::phase::Phase; +use crate::qubit_support::{assert_distinct_qubits, duplicate_qubits, overlapping_qubits}; use crate::{Angle64, Pauli, PauliString, QuarterPhase, QubitId}; use smallvec::SmallVec; use std::ops::{BitAnd, Mul, Neg}; @@ -68,7 +69,7 @@ use std::str::FromStr; /// /// ``` /// use pecos_core::{phase, Angle64}; -/// use pecos_core::unitary_rep::X; +/// use pecos_core::unitary::X; /// /// // e^{iπ/4} * X - exact, no floating point /// let op = phase!(pi / 4) * X(0); @@ -95,7 +96,7 @@ macro_rules! phase { /// /// ``` /// use pecos_core::phase_turn; -/// use pecos_core::unitary_rep::X; +/// use pecos_core::unitary::X; /// /// // T gate phase: e^{i * 2π/8} = e^{iπ/4} /// let op = phase_turn!(1 / 8) * X(0); @@ -386,7 +387,7 @@ pub enum Commutativity { /// /// Enables pluralized gate functions to accept various qubit collections: /// ``` -/// use pecos_core::unitary_rep::*; +/// use pecos_core::unitary::*; /// use pecos_core::QubitId; /// /// // Multiple qubits via Xs - equivalent to X(0) & X(2) & X(5) @@ -487,18 +488,18 @@ impl Qubits { where F: Fn(usize) -> UnitaryRep, { - match self.0.len() { - 0 => UnitaryRep::Pauli(PauliString::default()), // Identity - 1 => gate_fn(self.0[0].0), - _ => UnitaryRep::Tensor(self.0.iter().map(|q| gate_fn(q.0)).collect()), - } + self.0 + .iter() + .map(|q| gate_fn(q.0)) + .reduce(|lhs, rhs| lhs & rhs) + .unwrap_or_else(|| UnitaryRep::Pauli(PauliString::default())) } } /// Wrapper for qubit pairs used by pluralized two-qubit gates. /// /// ``` -/// use pecos_core::unitary_rep::*; +/// use pecos_core::unitary::*; /// use pecos_core::QubitId; /// /// // Multiple CX gates via CXs @@ -594,11 +595,11 @@ impl QubitPairs { where F: Fn(usize, usize) -> UnitaryRep, { - match self.0.len() { - 0 => UnitaryRep::Pauli(PauliString::default()), // Identity - 1 => gate_fn(self.0[0].0.0, self.0[0].1.0), - _ => UnitaryRep::Tensor(self.0.iter().map(|(q0, q1)| gate_fn(q0.0, q1.0)).collect()), - } + self.0 + .iter() + .map(|(q0, q1)| gate_fn(q0.0, q1.0)) + .reduce(|lhs, rhs| lhs & rhs) + .unwrap_or_else(|| UnitaryRep::Pauli(PauliString::default())) } } @@ -756,6 +757,22 @@ fn parse_qubits(tokens: &[&str]) -> Result, ParseUnitaryRepError> { .collect() } +fn require_distinct_parsed_qubits( + context: &str, + qubits: &[usize], +) -> Result<(), ParseUnitaryRepError> { + let duplicates = duplicate_qubits(qubits.iter().copied()); + if duplicates.is_empty() { + Ok(()) + } else { + Err(ParseUnitaryRepError { + message: format!( + "{context} requires distinct qubits; duplicated qubits: {duplicates:?}" + ), + }) + } +} + impl FromStr for UnitaryRep { type Err = ParseUnitaryRepError; @@ -840,6 +857,9 @@ impl FromStr for UnitaryRep { ), }); } + if expected > 1 { + require_distinct_parsed_qubits(rot_name, &qubits)?; + } return Ok(UnitaryRep::rotation( rot_type, angle, @@ -877,6 +897,9 @@ impl FromStr for UnitaryRep { ), }); } + if expected > 1 { + require_distinct_parsed_qubits(gate_name, &qubits)?; + } Ok(UnitaryRep::gate(gate_type, SmallVec::from_vec(qubits))) } @@ -942,6 +965,7 @@ impl FromStr for UnitaryRep { message: format!("{gate_name} requires 2 qubits, got {}", qubits.len()), }); } + require_distinct_parsed_qubits(gate_name, &qubits)?; Ok(UnitaryRep::gate(GateType::CX, SmallVec::from_vec(qubits))) } "CY" => { @@ -951,6 +975,7 @@ impl FromStr for UnitaryRep { message: format!("CY requires 2 qubits, got {}", qubits.len()), }); } + require_distinct_parsed_qubits("CY", &qubits)?; Ok(UnitaryRep::gate(GateType::CY, SmallVec::from_vec(qubits))) } "CZ" => { @@ -960,6 +985,7 @@ impl FromStr for UnitaryRep { message: format!("CZ requires 2 qubits, got {}", qubits.len()), }); } + require_distinct_parsed_qubits("CZ", &qubits)?; Ok(UnitaryRep::gate(GateType::CZ, SmallVec::from_vec(qubits))) } "SWAP" => { @@ -969,6 +995,7 @@ impl FromStr for UnitaryRep { message: format!("SWAP requires 2 qubits, got {}", qubits.len()), }); } + require_distinct_parsed_qubits("SWAP", &qubits)?; Ok(UnitaryRep::gate(GateType::SWAP, SmallVec::from_vec(qubits))) } @@ -980,6 +1007,7 @@ impl FromStr for UnitaryRep { message: format!("{gate_name} requires 3 qubits, got {}", qubits.len()), }); } + require_distinct_parsed_qubits(gate_name, &qubits)?; Ok(UnitaryRep::gate(GateType::CCX, SmallVec::from_vec(qubits))) } @@ -994,25 +1022,61 @@ impl FromStr for UnitaryRep { impl UnitaryRep { /// Creates a rotation gate expression. + /// + /// # Panics + /// + /// Panics if `qubits` does not match the rotation arity, or if a + /// multi-qubit rotation repeats a qubit. #[must_use] pub fn rotation( rotation_type: RotationType, angle: Angle64, qubits: impl Into>, ) -> Self { + let qubits = qubits.into(); + let expected = rotation_type.num_qubits(); + assert_eq!( + qubits.len(), + expected, + "{:?} requires {expected} qubit(s), got {}", + rotation_type.to_gate_type(), + qubits.len() + ); + if expected > 1 { + assert_distinct_qubits( + &format!("{:?}", rotation_type.to_gate_type()), + qubits.iter().copied(), + ); + } Self::Gate( Unitary::Rotation { rotation_type, angle, }, - qubits.into(), + qubits, ) } /// Creates a fixed gate expression. + /// + /// # Panics + /// + /// Panics if `qubits` does not match the gate arity, or if a + /// multi-qubit gate repeats a qubit. #[must_use] pub fn gate(gate_type: GateType, qubits: impl Into>) -> Self { - Self::Gate(Unitary::Named(gate_type), qubits.into()) + let qubits = qubits.into(); + let expected = gate_type.quantum_arity(); + assert_eq!( + qubits.len(), + expected, + "{gate_type:?} requires {expected} qubit(s), got {}", + qubits.len() + ); + if expected > 1 { + assert_distinct_qubits(&format!("{gate_type:?}"), qubits.iter().copied()); + } + Self::Gate(Unitary::Named(gate_type), qubits) } /// Returns the adjoint (Hermitian conjugate) of this expression. @@ -1251,7 +1315,7 @@ impl Mul<&UnitaryRep> for NegImaginaryUnit { /// /// # Example /// ``` -/// use pecos_core::unitary_rep::{phase, X}; +/// use pecos_core::unitary::{phase, X}; /// use pecos_core::Angle64; /// /// // Create a phase of e^{iπ/4} @@ -1267,7 +1331,7 @@ pub struct PhaseValue(pub Angle64); /// /// # Example /// ``` -/// use pecos_core::unitary_rep::{phase, X, Z}; +/// use pecos_core::unitary::{phase, X, Z}; /// use pecos_core::Angle64; /// /// // e^{iπ/4} * X @@ -1384,7 +1448,7 @@ impl UnitaryRep { /// # Example /// /// ``` - /// use pecos_core::unitary_rep::{Xs, Zs}; + /// use pecos_core::unitary::{Xs, Zs}; /// use pecos_core::PauliOperator; /// /// // Tensor of Paulis on disjoint qubits @@ -1402,10 +1466,10 @@ impl UnitaryRep { let mut result = PauliString::new(); for part in parts { let ps = part.try_to_pauli_string()?; - // Merge: combine the Pauli operators - // For disjoint qubits, this is just concatenation - // For overlapping qubits, we multiply the Paulis - result = result * ps; + if !overlapping_qubits(result.qubits(), ps.qubits()).is_empty() { + return None; + } + result = result & ps; } Some(result) } @@ -1450,6 +1514,10 @@ impl UnitaryRep { }, qubits, ) => { + if angle == Angle64::ZERO { + return Some(PauliString::identity()); + } + // Only half-turn rotations are Pauli operators let half = Angle64::HALF_TURN; let neg_half = negate_angle(half); @@ -1643,7 +1711,7 @@ impl UnitaryRep { /// # Example /// /// ``` - /// use pecos_core::unitary_rep::{X, Z, H, T}; + /// use pecos_core::unitary::{X, Z, H, T}; /// /// // Stabilizer update: applying H to qubit 0 /// let stabilizer = X(0) & Z(1); @@ -1668,7 +1736,7 @@ impl UnitaryRep { /// # Example /// /// ``` - /// use pecos_core::unitary_rep::{X, H}; + /// use pecos_core::unitary::{X, H}; /// /// // Heisenberg evolution: how X evolves under H /// let evolved = X(0).conjdg(&H(0)); // H† X H @@ -1683,7 +1751,7 @@ impl UnitaryRep { /// # Example /// /// ``` - /// use pecos_core::unitary_rep::{X, Y}; + /// use pecos_core::unitary::{X, Y}; /// use pecos_core::{GlobalPhase, QuarterPhase}; /// /// let op = X(0); @@ -1708,7 +1776,7 @@ impl UnitaryRep { /// # Example /// /// ``` - /// use pecos_core::unitary_rep::{X, Z, CX}; + /// use pecos_core::unitary::{X, Z, CX}; /// /// assert_eq!(X(0).weight(), 1); /// assert_eq!((X(0) & Z(2)).weight(), 2); @@ -1724,7 +1792,7 @@ impl UnitaryRep { /// # Example /// /// ``` - /// use pecos_core::unitary_rep::I; + /// use pecos_core::unitary::I; /// /// assert!(I(0).is_identity()); /// ``` @@ -1744,7 +1812,7 @@ impl UnitaryRep { /// # Example /// /// ``` - /// use pecos_core::unitary_rep::{X, Y, Z, H, T}; + /// use pecos_core::unitary::{X, Y, Z, H, T}; /// /// // Paulis are Hermitian /// assert!(X(0).is_hermitian()); @@ -1806,7 +1874,7 @@ impl UnitaryRep { /// # Example /// /// ``` - /// use pecos_core::unitary_rep::{X, H}; + /// use pecos_core::unitary::{X, H}; /// /// let x = X(0); /// let x2 = x.pow(2); // X * X = I @@ -1843,7 +1911,7 @@ impl UnitaryRep { /// # Example /// /// ``` - /// use pecos_core::unitary_rep::{X, Z, Commutativity}; + /// use pecos_core::unitary::{X, Z, Commutativity}; /// /// let a = X(0); /// let b = Z(0); @@ -1876,7 +1944,7 @@ impl UnitaryRep { /// # Example /// /// ``` - /// use pecos_core::unitary_rep::{X, H, RZ}; + /// use pecos_core::unitary::{X, H, RZ}; /// use pecos_core::Angle64; /// /// assert!(X(0).is_unitary()); @@ -1896,7 +1964,7 @@ impl UnitaryRep { /// # Example /// /// ``` - /// use pecos_core::unitary_rep::{H, CX}; + /// use pecos_core::unitary::{H, CX}; /// /// let circuit = CX(0, 1) * H(0); // H then CX /// let gates = circuit.decompose(); @@ -2943,11 +3011,10 @@ pub fn RZs(angle: Angle64, qubits: impl Into) -> UnitaryRep { #[must_use] #[allow(non_snake_case)] pub fn RXX(angle: Angle64, q0: impl Into, q1: impl Into) -> UnitaryRep { - UnitaryRep::rotation( - RotationType::RXX, - angle, - smallvec::smallvec![q0.into().0, q1.into().0], - ) + let q0 = q0.into(); + let q1 = q1.into(); + assert_distinct_qubits("RXX", [q0.0, q1.0]); + UnitaryRep::rotation(RotationType::RXX, angle, smallvec::smallvec![q0.0, q1.0]) } /// RXX rotations on multiple qubit pairs. @@ -2956,9 +3023,7 @@ pub fn RXX(angle: Angle64, q0: impl Into, q1: impl Into) -> Un #[must_use] #[allow(non_snake_case)] pub fn RXXs(angle: Angle64, pairs: impl Into) -> UnitaryRep { - pairs - .into() - .apply(|q0, q1| UnitaryRep::rotation(RotationType::RXX, angle, smallvec::smallvec![q0, q1])) + pairs.into().apply(|q0, q1| RXX(angle, q0, q1)) } /// Two-qubit YY rotation by the given angle. @@ -2967,11 +3032,10 @@ pub fn RXXs(angle: Angle64, pairs: impl Into) -> UnitaryRep { #[must_use] #[allow(non_snake_case)] pub fn RYY(angle: Angle64, q0: impl Into, q1: impl Into) -> UnitaryRep { - UnitaryRep::rotation( - RotationType::RYY, - angle, - smallvec::smallvec![q0.into().0, q1.into().0], - ) + let q0 = q0.into(); + let q1 = q1.into(); + assert_distinct_qubits("RYY", [q0.0, q1.0]); + UnitaryRep::rotation(RotationType::RYY, angle, smallvec::smallvec![q0.0, q1.0]) } /// RYY rotations on multiple qubit pairs. @@ -2980,9 +3044,7 @@ pub fn RYY(angle: Angle64, q0: impl Into, q1: impl Into) -> Un #[must_use] #[allow(non_snake_case)] pub fn RYYs(angle: Angle64, pairs: impl Into) -> UnitaryRep { - pairs - .into() - .apply(|q0, q1| UnitaryRep::rotation(RotationType::RYY, angle, smallvec::smallvec![q0, q1])) + pairs.into().apply(|q0, q1| RYY(angle, q0, q1)) } /// Two-qubit ZZ rotation by the given angle. @@ -2991,11 +3053,10 @@ pub fn RYYs(angle: Angle64, pairs: impl Into) -> UnitaryRep { #[must_use] #[allow(non_snake_case)] pub fn RZZ(angle: Angle64, q0: impl Into, q1: impl Into) -> UnitaryRep { - UnitaryRep::rotation( - RotationType::RZZ, - angle, - smallvec::smallvec![q0.into().0, q1.into().0], - ) + let q0 = q0.into(); + let q1 = q1.into(); + assert_distinct_qubits("RZZ", [q0.0, q1.0]); + UnitaryRep::rotation(RotationType::RZZ, angle, smallvec::smallvec![q0.0, q1.0]) } /// RZZ rotations on multiple qubit pairs. @@ -3004,9 +3065,7 @@ pub fn RZZ(angle: Angle64, q0: impl Into, q1: impl Into) -> Un #[must_use] #[allow(non_snake_case)] pub fn RZZs(angle: Angle64, pairs: impl Into) -> UnitaryRep { - pairs - .into() - .apply(|q0, q1| UnitaryRep::rotation(RotationType::RZZ, angle, smallvec::smallvec![q0, q1])) + pairs.into().apply(|q0, q1| RZZ(angle, q0, q1)) } // --- Gate constructors - Named single-qubit Cliffords --- @@ -3190,10 +3249,10 @@ pub fn Hs(qubits: impl Into) -> UnitaryRep { #[must_use] #[allow(non_snake_case)] pub fn CX(control: impl Into, target: impl Into) -> UnitaryRep { - UnitaryRep::gate( - GateType::CX, - smallvec::smallvec![control.into().0, target.into().0], - ) + let control = control.into(); + let target = target.into(); + assert_distinct_qubits("CX", [control.0, target.0]); + UnitaryRep::gate(GateType::CX, smallvec::smallvec![control.0, target.0]) } /// CX gates on multiple qubit pairs. @@ -3202,9 +3261,7 @@ pub fn CX(control: impl Into, target: impl Into) -> UnitaryRep #[must_use] #[allow(non_snake_case)] pub fn CXs(pairs: impl Into) -> UnitaryRep { - pairs - .into() - .apply(|ctrl, tgt| UnitaryRep::gate(GateType::CX, smallvec::smallvec![ctrl, tgt])) + pairs.into().apply(CX) } /// Controlled-Y gate. @@ -3213,10 +3270,10 @@ pub fn CXs(pairs: impl Into) -> UnitaryRep { #[must_use] #[allow(non_snake_case)] pub fn CY(control: impl Into, target: impl Into) -> UnitaryRep { - UnitaryRep::gate( - GateType::CY, - smallvec::smallvec![control.into().0, target.into().0], - ) + let control = control.into(); + let target = target.into(); + assert_distinct_qubits("CY", [control.0, target.0]); + UnitaryRep::gate(GateType::CY, smallvec::smallvec![control.0, target.0]) } /// CY gates on multiple qubit pairs. @@ -3225,9 +3282,7 @@ pub fn CY(control: impl Into, target: impl Into) -> UnitaryRep #[must_use] #[allow(non_snake_case)] pub fn CYs(pairs: impl Into) -> UnitaryRep { - pairs - .into() - .apply(|ctrl, tgt| UnitaryRep::gate(GateType::CY, smallvec::smallvec![ctrl, tgt])) + pairs.into().apply(CY) } /// Controlled-Z gate. @@ -3236,7 +3291,10 @@ pub fn CYs(pairs: impl Into) -> UnitaryRep { #[must_use] #[allow(non_snake_case)] pub fn CZ(q0: impl Into, q1: impl Into) -> UnitaryRep { - UnitaryRep::gate(GateType::CZ, smallvec::smallvec![q0.into().0, q1.into().0]) + let q0 = q0.into(); + let q1 = q1.into(); + assert_distinct_qubits("CZ", [q0.0, q1.0]); + UnitaryRep::gate(GateType::CZ, smallvec::smallvec![q0.0, q1.0]) } /// CZ gates on multiple qubit pairs. @@ -3245,9 +3303,7 @@ pub fn CZ(q0: impl Into, q1: impl Into) -> UnitaryRep { #[must_use] #[allow(non_snake_case)] pub fn CZs(pairs: impl Into) -> UnitaryRep { - pairs - .into() - .apply(|q0, q1| UnitaryRep::gate(GateType::CZ, smallvec::smallvec![q0, q1])) + pairs.into().apply(CZ) } /// SWAP gate. @@ -3256,10 +3312,10 @@ pub fn CZs(pairs: impl Into) -> UnitaryRep { #[must_use] #[allow(non_snake_case)] pub fn SWAP(q0: impl Into, q1: impl Into) -> UnitaryRep { - UnitaryRep::gate( - GateType::SWAP, - smallvec::smallvec![q0.into().0, q1.into().0], - ) + let q0 = q0.into(); + let q1 = q1.into(); + assert_distinct_qubits("SWAP", [q0.0, q1.0]); + UnitaryRep::gate(GateType::SWAP, smallvec::smallvec![q0.0, q1.0]) } /// SWAP gates on multiple qubit pairs. @@ -3268,9 +3324,7 @@ pub fn SWAP(q0: impl Into, q1: impl Into) -> UnitaryRep { #[must_use] #[allow(non_snake_case)] pub fn SWAPs(pairs: impl Into) -> UnitaryRep { - pairs - .into() - .apply(|q0, q1| UnitaryRep::gate(GateType::SWAP, smallvec::smallvec![q0, q1])) + pairs.into().apply(SWAP) } /// SZZ gate: RZZ(π/2) @@ -3279,10 +3333,13 @@ pub fn SWAPs(pairs: impl Into) -> UnitaryRep { #[must_use] #[allow(non_snake_case)] pub fn SZZ(q0: impl Into, q1: impl Into) -> UnitaryRep { + let q0 = q0.into(); + let q1 = q1.into(); + assert_distinct_qubits("SZZ", [q0.0, q1.0]); UnitaryRep::rotation( RotationType::RZZ, Angle64::QUARTER_TURN, - smallvec::smallvec![q0.into().0, q1.into().0], + smallvec::smallvec![q0.0, q1.0], ) } @@ -3292,13 +3349,7 @@ pub fn SZZ(q0: impl Into, q1: impl Into) -> UnitaryRep { #[must_use] #[allow(non_snake_case)] pub fn SZZs(pairs: impl Into) -> UnitaryRep { - pairs.into().apply(|q0, q1| { - UnitaryRep::rotation( - RotationType::RZZ, - Angle64::QUARTER_TURN, - smallvec::smallvec![q0, q1], - ) - }) + pairs.into().apply(SZZ) } // --- Gate constructors - Three-qubit gates --- @@ -3311,19 +3362,25 @@ pub fn CCX( c1: impl Into, target: impl Into, ) -> UnitaryRep { - UnitaryRep::gate( - GateType::CCX, - smallvec::smallvec![c0.into().0, c1.into().0, target.into().0], - ) + let c0 = c0.into(); + let c1 = c1.into(); + let target = target.into(); + assert_distinct_qubits("CCX", [c0.0, c1.0, target.0]); + UnitaryRep::gate(GateType::CCX, smallvec::smallvec![c0.0, c1.0, target.0]) } // --- UnitaryRep implementations --- -// Tensor product: & impl BitAnd for UnitaryRep { type Output = UnitaryRep; fn bitand(self, rhs: UnitaryRep) -> UnitaryRep { + let overlap = overlapping_qubits(self.qubits(), rhs.qubits()); + assert!( + overlap.is_empty(), + "tensor product requires disjoint unitary support; overlapping qubits: {overlap:?}" + ); + match (self, rhs) { // Pauli & Pauli: use PauliString tensor product (UnitaryRep::Pauli(a), UnitaryRep::Pauli(b)) => UnitaryRep::Pauli(a & b), @@ -3653,6 +3710,12 @@ mod tests { assert!(matches!(mixed, UnitaryRep::Tensor(_))); } + #[test] + #[should_panic(expected = "tensor product requires disjoint unitary support")] + fn test_tensor_product_rejects_overlapping_qubits() { + let _ = X(0) & H(0); + } + #[test] fn test_composition() { let circuit = T(0) * H(0); @@ -3671,6 +3734,22 @@ mod tests { assert!(cz.is_clifford()); } + #[test] + #[should_panic(expected = "CX requires 2 qubit(s), got 1")] + fn test_low_level_gate_constructor_rejects_wrong_arity() { + let _ = UnitaryRep::gate(GateType::CX, smallvec::smallvec![0]); + } + + #[test] + #[should_panic(expected = "RXX requires 2 qubit(s), got 1")] + fn test_low_level_rotation_constructor_rejects_wrong_arity() { + let _ = UnitaryRep::rotation( + RotationType::RXX, + Angle64::QUARTER_TURN, + smallvec::smallvec![0], + ); + } + #[test] fn test_adjoint() { let t = T(0); @@ -4218,6 +4297,28 @@ mod tests { } } + #[test] + fn test_try_to_pauli_string_zero_rotation_is_identity() { + for unitary in [ + I(0), + RX(Angle64::ZERO, 0), + RY(Angle64::ZERO, 1), + RZ(Angle64::ZERO, 2), + ] { + let ps = unitary + .try_to_pauli_string() + .expect("zero rotation should convert to identity Pauli"); + assert_eq!(ps.weight(), 0); + } + } + + #[test] + fn test_try_to_pauli_string_rejects_overlapping_tensor_node() { + let invalid_tensor = UnitaryRep::Tensor(vec![X(0), Z(0)]); + + assert!(invalid_tensor.try_to_pauli_string().is_none()); + } + #[test] fn test_try_to_pauli_non_pauli() { // Quarter-turn rotations should not convert @@ -4354,6 +4455,100 @@ mod tests { let _ = Zs(1..=0); } + #[test] + #[should_panic(expected = "tensor product requires disjoint unitary support")] + fn test_plural_single_qubit_tensor_rejects_duplicate_qubits() { + let _ = Hs([0, 0]); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint unitary support")] + fn test_plural_two_qubit_tensor_rejects_overlapping_pairs() { + let _ = CXs([(0, 1), (1, 2)]); + } + + #[test] + #[should_panic(expected = "CX requires distinct qubits")] + fn test_two_qubit_gate_rejects_repeated_qubit() { + let _ = CX(0, 0); + } + + #[test] + #[should_panic(expected = "RZZ requires distinct qubits")] + fn test_two_qubit_rotation_rejects_repeated_qubit() { + let _ = RZZ(Angle64::QUARTER_TURN, 1, 1); + } + + #[test] + #[should_panic(expected = "CCX requires distinct qubits")] + fn test_three_qubit_gate_rejects_repeated_qubit() { + let _ = CCX(0, 1, 1); + } + + #[test] + #[should_panic(expected = "CX requires distinct qubits")] + fn test_low_level_gate_constructor_rejects_repeated_qubit() { + let _ = UnitaryRep::gate(GateType::CX, smallvec::smallvec![0, 0]); + } + + #[test] + #[should_panic(expected = "RXX requires distinct qubits")] + fn test_low_level_rotation_constructor_rejects_repeated_qubit() { + let _ = UnitaryRep::rotation( + RotationType::RXX, + Angle64::QUARTER_TURN, + smallvec::smallvec![2, 2], + ); + } + + #[test] + fn test_plural_helpers_match_chained_tensor_forms() { + assert_eq!(Xs([0, 2, 5]), X(0) & X(2) & X(5)); + assert_eq!(Ys([0, 2, 5]), Y(0) & Y(2) & Y(5)); + assert_eq!(Zs([0, 2, 5]), Z(0) & Z(2) & Z(5)); + assert_eq!(Ts([0, 1, 2]), T(0) & T(1) & T(2)); + assert_eq!(CXs([(0, 1), (2, 3)]), CX(0, 1) & CX(2, 3)); + assert_eq!(CZs([(0, 1), (2, 3)]), CZ(0, 1) & CZ(2, 3)); + assert_eq!(SWAPs([(0, 1), (2, 3)]), SWAP(0, 1) & SWAP(2, 3)); + assert_eq!(SZZs([(0, 1), (2, 3)]), SZZ(0, 1) & SZZ(2, 3)); + assert_eq!( + RZZs(Angle64::QUARTER_TURN, [(0, 1), (2, 3)]), + RZZ(Angle64::QUARTER_TURN, 0, 1) & RZZ(Angle64::QUARTER_TURN, 2, 3) + ); + } + + #[test] + fn test_plural_helpers_reject_duplicate_and_overlapping_supports() { + fn assert_tensor_overlap_panic(f: impl FnOnce() + std::panic::UnwindSafe) { + let err = std::panic::catch_unwind(f).expect_err("expected tensor overlap panic"); + let message = err + .downcast_ref::() + .map(String::as_str) + .or_else(|| err.downcast_ref::<&str>().copied()) + .unwrap_or(""); + assert!( + message.contains("tensor product requires disjoint"), + "unexpected panic message: {message}" + ); + } + + assert_tensor_overlap_panic(|| { + let _ = Xs([0, 0]); + }); + assert_tensor_overlap_panic(|| { + let _ = Ts([1, 1]); + }); + assert_tensor_overlap_panic(|| { + let _ = SWAPs([(0, 1), (1, 2)]); + }); + assert_tensor_overlap_panic(|| { + let _ = SZZs([(0, 2), (2, 3)]); + }); + assert_tensor_overlap_panic(|| { + let _ = RZZs(Angle64::QUARTER_TURN, [(0, 2), (2, 3)]); + }); + } + #[test] fn test_single_element_range() { // Xs(0..1) should be equivalent to X(0) @@ -5171,6 +5366,27 @@ mod tests { assert!("CCX 0 1".parse::().is_err()); } + #[test] + fn from_str_rejects_repeated_multi_qubit_gate_args() { + for (text, expected) in [ + ("CX 0 0", "CX requires distinct qubits"), + ("CY 0 0", "CY requires distinct qubits"), + ("CH 0 0", "CH requires distinct qubits"), + ("SWAP 2 2", "SWAP requires distinct qubits"), + ("RXX(pi/2) 1 1", "RXX requires distinct qubits"), + ("RYY(pi/2) 1 1", "RYY requires distinct qubits"), + ("RZZ(pi/2) 1 1", "RZZ requires distinct qubits"), + ("CCX 0 1 0", "CCX requires distinct qubits"), + ("TOFFOLI 0 0 1", "TOFFOLI requires distinct qubits"), + ] { + let err = text.parse::().unwrap_err(); + assert!( + err.message.contains(expected), + "{text} produced unexpected error: {err}" + ); + } + } + #[test] fn from_str_case_insensitive() { assert!("h 0".parse::().is_ok()); diff --git a/crates/pecos-cuquantum-sys/Cargo.toml b/crates/pecos-cuquantum-sys/Cargo.toml index 4c22c8171..a1b3dc4cd 100644 --- a/crates/pecos-cuquantum-sys/Cargo.toml +++ b/crates/pecos-cuquantum-sys/Cargo.toml @@ -26,8 +26,6 @@ env_logger.workspace = true [features] default = [] -# Generate bindings at build time (requires cuQuantum headers) -runtime-bindgen = [] [package.metadata.docs.rs] # Don't try to build docs on docs.rs (no CUDA/cuQuantum available) diff --git a/crates/pecos-cuquantum/src/lib.rs b/crates/pecos-cuquantum/src/lib.rs index 2cf231b17..7d54f46e6 100644 --- a/crates/pecos-cuquantum/src/lib.rs +++ b/crates/pecos-cuquantum/src/lib.rs @@ -89,6 +89,43 @@ pub fn is_cuquantum_available() -> bool { pecos_cuquantum_sys::is_available() } +/// Check if the cuStateVec backend can create a simulator on this machine. +/// +/// This is stricter than [`is_cuquantum_available`]: it verifies not only that +/// cuQuantum libraries can be loaded, but also that a CUDA device/runtime can +/// initialize the cuStateVec handle and allocate a minimal state vector. +#[must_use] +pub fn is_custatevec_usable() -> bool { + CuStateVec::new(1).is_ok() +} + +/// Check if the cuStabilizer backend can create a frame simulator. +/// +/// This is stricter than [`is_cuquantum_available`] and catches environments +/// where the libraries are present but the CUDA runtime cannot initialize. +#[must_use] +pub fn is_custabilizer_usable() -> bool { + CuFrameSimulator::new(1, 1, 1).is_ok() +} + +/// Check if the cuTensorNet backend can create a handle. +/// +/// This is stricter than [`is_cuquantum_available`] and catches environments +/// where the libraries are present but the CUDA runtime cannot initialize. +#[must_use] +pub fn is_cutensornet_usable() -> bool { + CuTensorNet::new().is_ok() +} + +/// Check if the cuDensityMat backend can create a simulator on this machine. +/// +/// This is stricter than [`is_cuquantum_available`] and catches environments +/// where the libraries are present but the CUDA runtime cannot initialize. +#[must_use] +pub fn is_cudensitymat_usable() -> bool { + CuDensityMat::new(1).is_ok() +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pecos-decoder-core/Cargo.toml b/crates/pecos-decoder-core/Cargo.toml index e165df193..0b1031d92 100644 --- a/crates/pecos-decoder-core/Cargo.toml +++ b/crates/pecos-decoder-core/Cargo.toml @@ -15,6 +15,8 @@ description = "Core traits and utilities for PECOS decoders" ndarray.workspace = true thiserror.workspace = true anyhow.workspace = true +pecos-random.workspace = true +rayon.workspace = true [lints] workspace = true diff --git a/crates/pecos-decoder-core/src/adaptive.rs b/crates/pecos-decoder-core/src/adaptive.rs new file mode 100644 index 000000000..c05d5cc18 --- /dev/null +++ b/crates/pecos-decoder-core/src/adaptive.rs @@ -0,0 +1,185 @@ +// 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. + +//! Adaptive decoder wrapper for time-varying noise. +//! +//! Wraps any `ObservableDecoder` with automatic rebuilding when the +//! noise model changes. The decoder factory is called with a new DEM +//! whenever `update_noise` is invoked. +//! +//! Use cases: +//! - Neutral atoms: noise drifts on hour timescales (calibration drift) +//! - Trapped ions: slow parameter drift between recalibrations +//! - Any platform where the DEM becomes stale + +use crate::ObservableDecoder; +use crate::errors::DecoderError; + +type DecoderFactory = dyn FnMut(&str) -> Result, DecoderError>; + +fn fraction(numerator: usize, denominator: usize) -> f64 { + let numerator = u32::try_from(numerator).expect("monitoring count fits in u32"); + let denominator = u32::try_from(denominator).expect("monitoring window fits in u32"); + f64::from(numerator) / f64::from(denominator) +} + +/// Adaptive decoder that rebuilds when noise changes. +/// +/// Holds a decoder factory and the current DEM. When `update_dem` is +/// called with a new DEM string, the decoder is rebuilt transparently. +pub struct AdaptiveDecoder { + decoder: Box, + factory: Box, + current_dem: String, + rebuild_count: usize, + /// Calibration monitoring: recent outcomes (true = logical error). + recent_outcomes: std::collections::VecDeque, + /// Size of the monitoring window. + monitoring_window: usize, + /// Error rate threshold above which recalibration is recommended. + recalibration_threshold: f64, +} + +impl AdaptiveDecoder { + /// Create from a DEM string and factory. + /// + /// # Errors + /// + /// Returns `DecoderError` if the factory fails. + pub fn new(dem: &str, mut factory: F) -> Result + where + F: FnMut(&str) -> Result, DecoderError> + 'static, + { + let decoder = factory(dem)?; + Ok(Self { + decoder, + factory: Box::new(factory), + current_dem: dem.to_string(), + rebuild_count: 0, + recent_outcomes: std::collections::VecDeque::new(), + monitoring_window: 1000, + recalibration_threshold: 0.1, + }) + } + + /// Update the DEM and rebuild the decoder. + /// + /// # Errors + /// + /// Returns `DecoderError` if the factory fails on the new DEM. + pub fn update_dem(&mut self, new_dem: &str) -> Result<(), DecoderError> { + self.decoder = (self.factory)(new_dem)?; + self.current_dem = new_dem.to_string(); + self.rebuild_count += 1; + Ok(()) + } + + /// Number of times the decoder has been rebuilt. + #[must_use] + pub fn rebuild_count(&self) -> usize { + self.rebuild_count + } + + /// The current DEM string. + #[must_use] + pub fn current_dem(&self) -> &str { + &self.current_dem + } + + /// Report a logical outcome for calibration monitoring. + /// + /// `was_logical_error`: true if this QEC cycle resulted in a logical error. + /// The adaptive decoder tracks recent error rate and signals when + /// recalibration may be needed (noise model drift). + pub fn report_outcome(&mut self, was_logical_error: bool) { + self.recent_outcomes.push_back(was_logical_error); + if self.recent_outcomes.len() > self.monitoring_window { + self.recent_outcomes.pop_front(); + } + } + + /// Check if recalibration is recommended. + /// + /// Returns true if the recent error rate exceeds `recalibration_threshold`. + /// This suggests the noise model has drifted and the DEM should be regenerated. + #[must_use] + pub fn should_recalibrate(&self) -> bool { + if self.recent_outcomes.len() < self.monitoring_window / 2 { + return false; // Not enough data + } + let errors = self.recent_outcomes.iter().filter(|&&e| e).count(); + let rate = fraction(errors, self.recent_outcomes.len()); + rate > self.recalibration_threshold + } + + /// Recent logical error rate from monitoring window. + #[must_use] + pub fn recent_error_rate(&self) -> f64 { + if self.recent_outcomes.is_empty() { + return 0.0; + } + let errors = self.recent_outcomes.iter().filter(|&&e| e).count(); + fraction(errors, self.recent_outcomes.len()) + } + + /// Set the monitoring window size and recalibration threshold. + pub fn set_monitoring(&mut self, window: usize, threshold: f64) { + self.monitoring_window = window; + self.recalibration_threshold = threshold; + } +} + +impl ObservableDecoder for AdaptiveDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + self.decoder.decode_to_observables(syndrome) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_adaptive_decode() { + 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) + } + } + Ok(Box::new(Zero)) + }); + assert!(dec.is_ok()); + let mut dec = dec.unwrap(); + assert_eq!(dec.decode_to_observables(&[0]).unwrap(), 0); + assert_eq!(dec.rebuild_count(), 0); + } + + #[test] + fn test_adaptive_update() { + 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) + } + } + Ok(Box::new(Zero)) + }) + .unwrap(); + + dec.update_dem("error(0.2) D0\n").unwrap(); + assert_eq!(dec.rebuild_count(), 1); + assert_eq!(dec.current_dem(), "error(0.2) D0\n"); + } +} diff --git a/crates/pecos-decoder-core/src/bp_matching.rs b/crates/pecos-decoder-core/src/bp_matching.rs new file mode 100644 index 000000000..7e8f6477f --- /dev/null +++ b/crates/pecos-decoder-core/src/bp_matching.rs @@ -0,0 +1,127 @@ +// 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. + +//! Belief-matching: BP soft info → reweighted matching decoder. +//! +//! Wraps any `MatchingDecoder` with BP-computed edge weights. +//! The BP posteriors are computed by an external `BpWeightProvider`, +//! then fed to the matching decoder via `decode_with_weights`. +//! +//! This achieves belief-matching (Higgott 2022) with any matching backend: +//! - Fusion Blossom (MWPM, ~0.94% threshold) +//! - UF (already in BP+UF, ~0.5% threshold) +//! - `PyMatching` (if it had dynamic weights) + +use crate::correlated_decoder::MatchingDecoder; +use crate::correlation_table::CorrelationTable; +use crate::errors::DecoderError; + +/// Trait for providing BP-adjusted weights per syndrome. +pub trait BpWeightProvider { + /// Compute BP-adjusted matching graph edge weights for a syndrome. + /// Returns one weight per matching graph edge. + fn compute_weights(&mut self, syndrome: &[u8]) -> Vec; + + /// Number of matching graph edges. + fn num_edges(&self) -> usize; + + /// Check if this syndrome is trivial (predecoder can handle it). + fn is_trivial(&self, syndrome: &[u8]) -> Option; +} + +/// Belief-matching decoder: BP weights → matching decoder. +/// +/// Optionally performs a second pass with correlation table adjustment +/// (correlated belief-matching) for exploiting X-Z cross-lattice correlations. +pub struct BpMatchingDecoder { + matching: M, + bp: B, + /// Optional correlation table for two-pass correlated decoding. + correlation: Option, + /// Reusable buffer for adjusted weights in the second pass. + adjusted_weights: Vec, +} + +impl BpMatchingDecoder { + /// Create a single-pass belief-matching decoder. + pub fn new(matching: M, bp: B) -> Self { + let n = bp.num_edges(); + Self { + matching, + bp, + correlation: None, + adjusted_weights: vec![0.0; n], + } + } + + /// Create a two-pass correlated belief-matching decoder. + /// + /// First pass: BP weights → MWPM → matched edges. + /// Second pass: correlation table adjusts weights → MWPM again. + pub fn with_correlations(matching: M, bp: B, correlation: CorrelationTable) -> Self { + let n = bp.num_edges(); + Self { + matching, + bp, + correlation: Some(correlation), + adjusted_weights: vec![0.0; n], + } + } +} + +impl crate::ObservableDecoder for BpMatchingDecoder { + fn decode_to_observables(&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 + // syndromes, but we can't easily detect d here. So we skip the + // 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); + } + + // Compute BP-adjusted weights. + let bp_weights = self.bp.compute_weights(syndrome); + + if let Some(corr) = &self.correlation + && corr.has_correlations() + { + // Two-pass correlated belief-matching. + + // First pass: decode with BP weights to get matched edges. + let (_, matched_edges) = self.matching.decode_with_weights(syndrome, &bp_weights)?; + + // Apply correlation adjustments to BP weights. + self.adjusted_weights.copy_from_slice(&bp_weights); + for &edge_idx in &matched_edges { + if edge_idx < corr.implied_weights.len() { + for iw in &corr.implied_weights[edge_idx] { + if iw.conditional_weight < self.adjusted_weights[iw.target_edge_idx] { + self.adjusted_weights[iw.target_edge_idx] = iw.conditional_weight; + } + } + } + } + + // Second pass: decode with correlation-adjusted weights. + let (obs, _) = self + .matching + .decode_with_weights(syndrome, &self.adjusted_weights)?; + return Ok(obs); + } + + // Single-pass belief-matching. + let (obs, _) = self.matching.decode_with_weights(syndrome, &bp_weights)?; + Ok(obs) + } +} diff --git a/crates/pecos-decoder-core/src/committed_osd.rs b/crates/pecos-decoder-core/src/committed_osd.rs new file mode 100644 index 000000000..8157cd6a8 --- /dev/null +++ b/crates/pecos-decoder-core/src/committed_osd.rs @@ -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. + +//! OSD with software commitment for streaming decoding. +//! +//! Wraps an `ObservableSubgraphDecoder` with per-detector commitment +//! tracking. Committed detectors are masked during future decodes, +//! implementing the "software commitment" concept from Cain et al. +//! (arXiv:2505.13587). +//! +//! This enables streaming: decode a region, commit it, decode the next +//! region. Only uncommitted detectors participate in matching. + +use crate::ObservableDecoder; +use crate::decode_budget::{DecodeStrategy, DetectorRegion}; +use crate::errors::DecoderError; +use crate::observable_subgraph::ObservableSubgraphDecoder; + +/// Observable subgraph decoder with software commitment. +/// +/// After decoding a region, call `commit_range()` to mark those +/// detectors as finalized. Future decodes will mask committed +/// detectors (treat as syndrome=0), preventing re-matching of +/// already-corrected errors. +/// +/// 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, + /// Per-detector commitment state. True = committed. + committed: Vec, + /// Accumulated observable correction from committed regions. + committed_obs: u64, + /// Total number of detectors. + num_detectors: usize, + /// Reusable masked syndrome buffer. + masked_syndrome: Vec, +} + +impl CommittedOsdDecoder { + /// Wrap an existing OSD with commitment tracking. + #[must_use] + pub fn new(inner: ObservableSubgraphDecoder, num_detectors: usize) -> Self { + Self { + inner, + committed: vec![false; num_detectors], + committed_obs: 0, + num_detectors, + masked_syndrome: vec![0u8; num_detectors], + } + } + + /// Decode only uncommitted detectors. + /// + /// Committed detectors are masked to 0 before passing to the + /// inner OSD. Returns the correction for the active (uncommitted) + /// region. + pub fn decode_active(&mut self, syndrome: &[u8]) -> Result { + // Build masked syndrome: zero out committed detectors + let len = syndrome.len().min(self.num_detectors); + self.masked_syndrome[..len].copy_from_slice(&syndrome[..len]); + for i in 0..len { + if self.committed[i] { + self.masked_syndrome[i] = 0; + } + } + self.inner + .decode_to_observables(&self.masked_syndrome[..len]) + } + + /// Mark detectors in [start, end) as committed. + /// + /// Before committing, decodes the full syndrome to get the + /// correction that includes the about-to-be-committed region. + /// The committed correction is stored for accumulation. + pub fn commit_range( + &mut self, + syndrome: &[u8], + region: &DetectorRegion, + ) -> Result { + // Decode with current syndrome (including uncommitted detectors) + let obs = self.decode_active(syndrome)?; + + // Mark detectors as committed + for i in region.start..region.end.min(self.num_detectors) { + self.committed[i] = true; + } + + // Accumulate the correction + self.committed_obs ^= obs; + Ok(obs) + } + + /// Total correction: committed + latest active. + /// + /// Call `decode_active` first to get the active correction, + /// then XOR with `committed_obs` for the full correction. + #[must_use] + pub fn committed_obs(&self) -> u64 { + self.committed_obs + } + + /// Number of committed detectors. + #[must_use] + pub fn num_committed(&self) -> usize { + self.committed.iter().filter(|&&c| c).count() + } + + /// Reset all commitment state for the next shot. + pub fn reset(&mut self) { + self.committed.fill(false); + self.committed_obs = 0; + } +} + +impl ObservableDecoder for CommittedOsdDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + // Full decode: committed XOR active + let active = self.decode_active(syndrome)?; + Ok(self.committed_obs ^ active) + } +} + +impl DecodeStrategy for CommittedOsdDecoder { + fn decode(&mut self, syndrome: &[u8]) -> Result { + self.decode_active(syndrome) + } + + fn commit(&mut self, region: &DetectorRegion) -> Result { + // Commit with zeros — the actual syndrome was already decoded + // via decode(). Just mark the region. + for i in region.start..region.end.min(self.num_detectors) { + self.committed[i] = true; + } + Ok(self.committed_obs) + } + + fn committed_obs(&self) -> u64 { + self.committed_obs + } + + fn reset(&mut self) { + CommittedOsdDecoder::reset(self); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_detector_region() { + let r = DetectorRegion { start: 5, end: 15 }; + assert_eq!(r.len(), 10); + assert!(r.contains(5)); + assert!(!r.contains(15)); + } + + #[test] + fn test_decode_strategy_trait() { + // Verify the trait exists and has the right methods + // (compile-time check via trait bound) + fn _assert_strategy() {} + } +} diff --git a/crates/pecos-decoder-core/src/correlated_decoder.rs b/crates/pecos-decoder-core/src/correlated_decoder.rs new file mode 100644 index 000000000..4c3f81e4c --- /dev/null +++ b/crates/pecos-decoder-core/src/correlated_decoder.rs @@ -0,0 +1,243 @@ +// 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. + +//! Two-pass correlated MWPM decoder (DGR-style). +//! +//! Wraps any `ObservableDecoder` that also supports a `decode_with_weights` +//! interface. After a training phase to build correlation statistics, each +//! shot is decoded twice: +//! +//! 1. First pass with alignment-corrected weights (from observed frequencies) +//! 2. Second pass with correlation-adjusted weights (from first-pass matching) +//! +//! This improves accuracy by exploiting pairwise edge correlations that +//! standard MWPM ignores. + +use crate::correlated_reweighting::EdgeCorrelationTracker; +use crate::errors::DecoderError; + +/// Configuration for the correlated decoder. +#[derive(Debug, Clone)] +pub struct CorrelatedDecoderConfig { + /// Number of training shots before enabling correlation re-weighting. + /// During training, shots are decoded normally and matchings are recorded. + pub training_shots: usize, + /// Whether to use alignment re-weighting (update base weights from + /// observed edge frequencies). + pub use_alignment: bool, + /// Whether to use correlation re-weighting (per-shot two-pass decode). + pub use_correlation: bool, +} + +impl Default for CorrelatedDecoderConfig { + fn default() -> Self { + Self { + training_shots: 1000, + use_alignment: true, + use_correlation: true, + } + } +} + +/// Trait for decoders that can report which edges were matched. +/// +/// This is needed for the correlation tracker to build statistics. +/// The decoder must return both the observable mask and the matched edge +/// indices from each decode. +pub trait MatchingDecoder { + /// Decode a syndrome and return (`observable_mask`, `matched_edge_indices`). + fn decode_with_matching(&mut self, syndrome: &[u8]) -> Result<(u64, Vec), DecoderError>; + + /// Decode with adjusted per-edge weights. + /// The weights slice has one entry per edge in the matching graph. + fn decode_with_weights( + &mut self, + syndrome: &[u8], + weights: &[f64], + ) -> Result<(u64, Vec), DecoderError>; + + /// Number of edges in the matching graph. + fn num_edges(&self) -> usize; +} + +/// Extension of `MatchingDecoder` that exposes per-edge metadata. +/// +/// Used by overlapping/sandwich windowed decoders to classify matched edges +/// as core or buffer based on endpoint locations and weight thresholds. +pub trait EdgeTrackingDecoder: MatchingDecoder { + /// First endpoint node index of the given edge. + fn edge_node1(&self, edge_idx: usize) -> u32; + + /// Second endpoint node index. Boundary nodes have index >= `num_detectors()`. + fn edge_node2(&self, edge_idx: usize) -> u32; + + /// Log-likelihood weight of the given edge. + fn edge_weight(&self, edge_idx: usize) -> f64; + + /// Observable bitmask for the given edge. + fn edge_obs_mask(&self, edge_idx: usize) -> u64; + + /// Number of detector nodes (not counting boundary/virtual nodes). + fn num_detectors(&self) -> usize; +} + +/// Two-pass correlated MWPM decoder. +/// +/// Wraps a `MatchingDecoder` with DGR-style correlation tracking and +/// re-weighting. Transparent to the `ObservableDecoder` interface -- +/// callers see the same API, just better accuracy. +pub struct CorrelatedDecoder { + inner: D, + tracker: EdgeCorrelationTracker, + config: CorrelatedDecoderConfig, + shots_decoded: usize, + /// Base weights (from DEM, updated by alignment after training). + base_weights: Vec, + /// Buffer for matched-edge flags (avoids per-shot allocation). + matched_flags: Vec, +} + +impl CorrelatedDecoder { + /// Create a new correlated decoder wrapping an inner decoder. + pub fn new(inner: D, base_weights: Vec, config: CorrelatedDecoderConfig) -> Self { + let num_edges = inner.num_edges(); + Self { + tracker: EdgeCorrelationTracker::new(num_edges), + matched_flags: vec![false; num_edges], + inner, + config, + shots_decoded: 0, + base_weights, + } + } + + /// Whether the training phase is complete. + #[must_use] + pub fn is_trained(&self) -> bool { + self.shots_decoded >= self.config.training_shots + } + + /// Number of training shots remaining. + #[must_use] + pub fn training_remaining(&self) -> usize { + self.config + .training_shots + .saturating_sub(self.shots_decoded) + } +} + +impl crate::ObservableDecoder for CorrelatedDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + self.shots_decoded += 1; + + // During training: decode normally, record matchings + if !self.is_trained() { + let (mask, matched_edges) = self.inner.decode_with_matching(syndrome)?; + self.tracker.record_matching(&matched_edges); + + // After training is complete, update base weights from alignment + if self.is_trained() && self.config.use_alignment { + self.base_weights = self.tracker.aligned_weights(); + } + + return Ok(mask); + } + + // After training: two-pass decode with correlation adjustment + + // First pass: decode with (possibly alignment-corrected) base weights + let (first_mask, first_matching) = self + .inner + .decode_with_weights(syndrome, &self.base_weights)?; + + // Record the matching for ongoing statistics + self.tracker.record_matching(&first_matching); + + if !self.config.use_correlation { + return Ok(first_mask); + } + + // Build matched-edge flags for correlation adjustment + self.matched_flags.fill(false); + for &e in &first_matching { + if e < self.matched_flags.len() { + self.matched_flags[e] = true; + } + } + + // Compute correlation-adjusted weights + let adjusted_weights = self + .tracker + .correlation_adjusted_weights(&self.base_weights, &self.matched_flags); + + // Second pass: re-decode with adjusted weights + let (second_mask, _) = self + .inner + .decode_with_weights(syndrome, &adjusted_weights)?; + + Ok(second_mask) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ObservableDecoder; + + /// Simple mock decoder for testing. + struct MockDecoder { + num_edges: usize, + } + + impl MatchingDecoder for MockDecoder { + fn decode_with_matching( + &mut self, + _syndrome: &[u8], + ) -> Result<(u64, Vec), DecoderError> { + Ok((0, vec![0, 2])) + } + + fn decode_with_weights( + &mut self, + _syndrome: &[u8], + _weights: &[f64], + ) -> Result<(u64, Vec), DecoderError> { + Ok((0, vec![0, 2])) + } + + fn num_edges(&self) -> usize { + self.num_edges + } + } + + #[test] + fn test_training_phase() { + let mock = MockDecoder { num_edges: 5 }; + let weights = vec![1.0; 5]; + let config = CorrelatedDecoderConfig { + training_shots: 3, + ..Default::default() + }; + let mut decoder = CorrelatedDecoder::new(mock, weights, config); + + assert!(!decoder.is_trained()); + assert_eq!(decoder.training_remaining(), 3); + + // Decode 3 training shots + for _ in 0..3 { + let _ = decoder.decode_to_observables(&[0, 0, 0]); + } + + assert!(decoder.is_trained()); + assert_eq!(decoder.training_remaining(), 0); + } +} diff --git a/crates/pecos-decoder-core/src/correlated_reweighting.rs b/crates/pecos-decoder-core/src/correlated_reweighting.rs new file mode 100644 index 000000000..edbc16633 --- /dev/null +++ b/crates/pecos-decoder-core/src/correlated_reweighting.rs @@ -0,0 +1,274 @@ +// 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. + +//! Correlated edge re-weighting for MWPM decoders. +//! +//! Implements a simplified version of the DGR (Decoding Graph Re-weighting) +//! scheme from arXiv:2311.16214. The key idea: after an initial MWPM decode, +//! adjust edge weights based on pairwise correlations between edges, then +//! re-decode with the adjusted weights. +//! +//! Two phases: +//! 1. **Alignment**: track edge frequencies across shots, update weights to +//! match observed probabilities (corrects for DEM inaccuracies). +//! 2. **Correlation**: for each shot, adjust weights based on which correlated +//! edges were/weren't in the initial matching, then re-decode. + +/// Tracks edge occurrence statistics for alignment and correlation re-weighting. +pub struct EdgeCorrelationTracker { + /// Number of edges in the matching graph. + num_edges: usize, + /// Number of shots observed. + num_shots: usize, + /// Per-edge occurrence count (how many times edge appeared in a matching). + edge_counts: Vec, + /// Pairwise co-occurrence counts: `co_counts`[i * `num_edges` + j] = count of + /// edges i and j both appearing in the same matching. + /// Only stores upper triangle (i < j) to save memory. + co_counts: Vec, +} + +impl EdgeCorrelationTracker { + /// Create a new tracker for the given number of edges. + #[must_use] + pub fn new(num_edges: usize) -> Self { + // For large graphs, the co-occurrence matrix is O(E^2). + // At d=5 with ~200 edges, this is ~40K entries (320KB) -- fine. + // At d=9 with ~2000 edges, this is ~4M entries (32MB) -- manageable. + let co_size = num_edges * (num_edges - 1) / 2; + Self { + num_edges, + num_shots: 0, + edge_counts: vec![0; num_edges], + co_counts: vec![0; co_size], + } + } + + /// Index into the upper-triangle co-occurrence array. + fn co_index(&self, i: usize, j: usize) -> usize { + let (a, b) = if i < j { (i, j) } else { (j, i) }; + a * self.num_edges - a * (a + 1) / 2 + b - a - 1 + } + + /// Record a matching result: which edges were selected. + pub fn record_matching(&mut self, matched_edges: &[usize]) { + self.num_shots += 1; + + // Update single-edge counts + for &e in matched_edges { + if e < self.num_edges { + self.edge_counts[e] += 1; + } + } + + // Update co-occurrence counts for all pairs in the matching + for (idx_a, &e_a) in matched_edges.iter().enumerate() { + for &e_b in &matched_edges[idx_a + 1..] { + if e_a < self.num_edges && e_b < self.num_edges && e_a != e_b { + let co_idx = self.co_index(e_a, e_b); + if co_idx < self.co_counts.len() { + self.co_counts[co_idx] += 1; + } + } + } + } + } + + /// Get the empirical probability of edge e appearing in a matching. + #[must_use] + pub fn edge_probability(&self, e: usize) -> f64 { + if self.num_shots == 0 || e >= self.num_edges { + return 0.0; + } + self.edge_counts[e] as f64 / self.num_shots as f64 + } + + /// Get the empirical co-occurrence probability of edges i and j. + #[must_use] + pub fn co_occurrence_probability(&self, i: usize, j: usize) -> f64 { + if self.num_shots == 0 || i >= self.num_edges || j >= self.num_edges || i == j { + return 0.0; + } + let co_idx = self.co_index(i, j); + if co_idx >= self.co_counts.len() { + return 0.0; + } + self.co_counts[co_idx] as f64 / self.num_shots as f64 + } + + /// Number of shots recorded. + #[must_use] + pub fn num_shots(&self) -> usize { + self.num_shots + } + + /// Compute alignment-reweighted edge weights. + /// + /// Replaces original DEM weights with weights derived from observed + /// edge frequencies: `w_e` = -`ln(p_e` / (1 - `p_e`)) where `p_e` is the + /// empirical edge probability. + #[must_use] + pub fn aligned_weights(&self) -> Vec { + (0..self.num_edges) + .map(|e| { + let p = self.edge_probability(e); + if p <= 0.0 || p >= 1.0 { + // Edge never/always matched -- keep a default weight + if p <= 0.0 { 20.0 } else { 0.0 } + } else { + ((1.0 - p) / p).ln() + } + }) + .collect() + } + + /// Compute correlation-adjusted weights for a specific matching. + /// + /// Given an initial matching M, adjust each edge weight based on + /// correlations with matched/unmatched edges (DGR Equation 1): + /// + /// `w̃_j` = `w_j` - Σ_{`e_i` ∈ M} `p(e_i,e_j)/p(e_i)` + Σ_{`e_i` ∉ M} `p(e_i,e_j)/p(e_i)` + /// + /// Intuition: if edge j is correlated with matched edges, decrease its + /// weight (make it more likely). If correlated with unmatched edges, + /// increase its weight (make it less likely). + /// Compute correlation-adjusted weights for a specific matching. + /// + /// Given an initial matching M, adjust each edge weight based on + /// correlations with matched/unmatched edges (DGR Equation 1): + /// + /// `w̃_j` = `w_j` - Σ_{`e_i` ∈ M} `p(e_i,e_j)/p(e_i)` + Σ_{`e_i` ∉ M} `p(e_i,e_j)/p(e_i)` + /// + /// Only edges with significant correlation (conditional probability > + /// `min_conditional`) contribute to the sum. This avoids the bias from + /// summing over hundreds of near-zero terms. + #[must_use] + pub fn correlation_adjusted_weights( + &self, + base_weights: &[f64], + matched_edges: &[bool], + ) -> Vec { + self.correlation_adjusted_weights_filtered(base_weights, matched_edges, 0.05) + } + + /// Correlation adjustment with configurable significance threshold. + /// + /// `min_conditional`: minimum p(i,j)/p(i) to include an edge pair. + /// Higher values mean fewer pairs contribute (sparser adjustment). + #[must_use] + pub fn correlation_adjusted_weights_filtered( + &self, + base_weights: &[f64], + matched_edges: &[bool], + min_conditional: f64, + ) -> Vec { + let mut adjusted = base_weights.to_vec(); + + for (j, adjusted_weight) in adjusted.iter_mut().enumerate().take(self.num_edges) { + let mut adjustment = 0.0; + for (i, matched) in matched_edges.iter().enumerate().take(self.num_edges) { + if i == j { + continue; + } + let p_i = self.edge_probability(i); + if p_i <= 0.0 { + continue; + } + let p_ij = self.co_occurrence_probability(i, j); + let conditional = p_ij / p_i; + + // Only include significantly correlated pairs + if conditional < min_conditional { + continue; + } + + if *matched { + // Correlated edge is matched -> decrease weight (more likely). + // We only adjust for matched edges -- the unmatched term + // creates a large positive bias that dominates when most + // edges are unmatched. + adjustment -= conditional; + } + } + *adjusted_weight += adjustment; + // Clamp to prevent negative weights + if *adjusted_weight < 0.0 { + *adjusted_weight = 0.0; + } + } + + adjusted + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_tracker_basic() { + let mut tracker = EdgeCorrelationTracker::new(5); + + // Record some matchings + tracker.record_matching(&[0, 2]); + tracker.record_matching(&[0, 3]); + tracker.record_matching(&[1, 2]); + tracker.record_matching(&[0, 2]); + + assert_eq!(tracker.num_shots(), 4); + assert!((tracker.edge_probability(0) - 0.75).abs() < 1e-10); + assert!((tracker.edge_probability(1) - 0.25).abs() < 1e-10); + assert!((tracker.edge_probability(2) - 0.75).abs() < 1e-10); + assert!((tracker.edge_probability(4) - 0.0).abs() < 1e-10); + + // Co-occurrence: edges 0 and 2 both appear in 2 matchings + assert!((tracker.co_occurrence_probability(0, 2) - 0.5).abs() < 1e-10); + // Edges 0 and 3 both appear in 1 matching + assert!((tracker.co_occurrence_probability(0, 3) - 0.25).abs() < 1e-10); + } + + #[test] + fn test_aligned_weights() { + let mut tracker = EdgeCorrelationTracker::new(3); + for _ in 0..100 { + tracker.record_matching(&[0]); + } + for _ in 0..900 { + tracker.record_matching(&[]); + } + // Edge 0 appears in 10% of matchings -> p=0.1 -> w = ln(0.9/0.1) = ln(9) ≈ 2.197 + let weights = tracker.aligned_weights(); + assert!((weights[0] - 9.0_f64.ln()).abs() < 0.01); + } + + #[test] + fn test_correlation_adjustment() { + let mut tracker = EdgeCorrelationTracker::new(3); + // Edges 0 and 1 are highly correlated (always appear together) + for _ in 0..100 { + tracker.record_matching(&[0, 1]); + } + for _ in 0..900 { + tracker.record_matching(&[]); + } + + let base_weights = vec![2.0, 2.0, 2.0]; + // If edge 0 is matched, edge 1's weight should decrease (correlated) + let matched = vec![true, false, false]; + let adjusted = tracker.correlation_adjusted_weights(&base_weights, &matched); + + // Edge 1 should have lower weight (more likely given edge 0 is matched) + assert!(adjusted[1] < base_weights[1]); + // Edge 2 should be unchanged (no correlation with edge 0) + assert!((adjusted[2] - base_weights[2]).abs() < 1e-10); + } +} diff --git a/crates/pecos-decoder-core/src/correlation_table.rs b/crates/pecos-decoder-core/src/correlation_table.rs new file mode 100644 index 000000000..1e2345a5c --- /dev/null +++ b/crates/pecos-decoder-core/src/correlation_table.rs @@ -0,0 +1,275 @@ +// 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. + +//! Pre-computed correlation table from DEM decomposition. +//! +//! Extracts pairwise edge correlations from the `^` decomposition in a DEM +//! and pre-computes conditional weights for two-pass correlated decoding. +//! +//! The algorithm (matching `PyMatching`'s implementation): +//! +//! 1. Parse decomposed error mechanisms (with `^` separators) +//! 2. For each pair of components (A, B) in a decomposed mechanism with +//! probability p, accumulate joint probability: `p_joint = p_a*(1-p) + p*(1-p_a)` +//! 3. Also accumulate marginal probability for each edge +//! 4. Conditional probability: `P(B|A) = P_joint(A,B) / P_marginal(A)` +//! 5. Conditional weight: `w_cond = ln((1-P(B|A)) / P(B|A))` +//! +//! The conditional weight is only applied during decoding if it's LOWER than +//! the current weight (makes the correlated edge more likely). + +use crate::errors::DecoderError; +use std::collections::BTreeMap; + +/// An implied weight change: if edge (node1, node2) was matched, +/// change the weight of edge (`target_node1`, `target_node2`) to `conditional_weight`. +#[derive(Debug, Clone)] +pub struct ImpliedWeight { + /// Target edge (by index in the matching graph). + pub target_edge_idx: usize, + /// Conditional weight: the weight edge should have given the source was matched. + pub conditional_weight: f64, +} + +/// Pre-computed correlation table from DEM decomposition. +/// +/// For each edge in the matching graph, stores a list of implied weight +/// changes that should be applied when that edge is matched in the first +/// pass of a two-pass decode. +#[derive(Debug, Clone)] +pub struct CorrelationTable { + /// For each edge index, the list of implied weights for correlated edges. + pub implied_weights: Vec>, + /// Number of edges. + pub num_edges: usize, +} + +/// Edge key for lookup: (`min_node`, `max_node`), where `max_node` = `u32::MAX` for boundary. +type EdgeKey = (u32, u32); + +/// Independent probability combination (Bernoulli XOR): +/// `p_combined = p_a * (1 - p_b) + p_b * (1 - p_a)` +fn bernoulli_xor(p_a: f64, p_b: f64) -> f64 { + p_a * (1.0 - p_b) + p_b * (1.0 - p_a) +} + +/// Convert probability to weight for correlations: `ln((1-p) / p)` +/// Clamped to avoid infinite weights. +fn prob_to_weight(p: f64) -> f64 { + let p_clamped = p.clamp(1e-15, 0.5); + ((1.0 - p_clamped) / p_clamped).ln() +} + +impl CorrelationTable { + /// Build a correlation table from a DEM string. + /// + /// Parses the DEM, identifies decomposed error mechanisms (with `^`), + /// computes joint probabilities between component pairs, and derives + /// conditional weights. + /// + /// `edge_index_map` maps (node1, node2) pairs to edge indices in the + /// matching graph (after merging). This must match the decoder's edge + /// indexing. + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed. + pub fn from_dem_str( + dem: &str, + edge_index_map: &BTreeMap, + num_edges: usize, + ) -> Result { + // Accumulate joint and marginal probabilities. + // joint_probs[(edge_A, edge_B)] = P(A and B both fire from shared mechanisms) + // marginal_probs[edge_A] = P(A fires from any mechanism) + let mut joint_probs: BTreeMap<(EdgeKey, EdgeKey), f64> = BTreeMap::new(); + + for line in dem.lines() { + let line = line.trim(); + if !line.starts_with("error(") { + continue; + } + + let close_paren = line.find(')').ok_or_else(|| { + DecoderError::InvalidConfiguration("Missing closing parenthesis".into()) + })?; + let prob_str = &line[6..close_paren]; + let probability: f64 = prob_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!("Invalid probability: {prob_str}")) + })?; + + if probability <= 0.0 || probability > 0.5 { + continue; + } + + let tokens_str = &line[close_paren + 1..]; + let components: Vec<&str> = tokens_str.split('^').collect(); + + if components.len() < 2 { + // Non-decomposed mechanism: accumulate marginal only + let key = parse_component_edge_key(components[0]); + if let Some(key) = key { + let marginal = joint_probs.entry((key, key)).or_insert(0.0); + *marginal = bernoulli_xor(*marginal, probability); + } + continue; + } + + // Decomposed mechanism: accumulate joint and marginal for all pairs + let mut component_keys: Vec = Vec::new(); + for component in &components { + if let Some(key) = parse_component_edge_key(component) { + component_keys.push(key); + } + } + + // Joint probabilities for all pairs + for i in 0..component_keys.len() { + for j in (i + 1)..component_keys.len() { + let k0 = component_keys[i]; + let k1 = component_keys[j]; + let p01 = joint_probs.entry((k0, k1)).or_insert(0.0); + *p01 = bernoulli_xor(*p01, probability); + let p10 = joint_probs.entry((k1, k0)).or_insert(0.0); + *p10 = bernoulli_xor(*p10, probability); + } + } + + // Marginal for each component + for &key in &component_keys { + let marginal = joint_probs.entry((key, key)).or_insert(0.0); + *marginal = bernoulli_xor(*marginal, probability); + } + } + + // Build implied weight table + let mut implied_weights: Vec> = vec![Vec::new(); num_edges]; + + for (&(causal_key, affected_key), &joint_p) in &joint_probs { + if causal_key == affected_key { + continue; // Skip marginals + } + + let marginal_p = joint_probs + .get(&(causal_key, causal_key)) + .copied() + .unwrap_or(0.0); + if marginal_p <= 0.0 { + continue; + } + + let conditional_p = (joint_p / marginal_p).min(0.5); + if conditional_p <= 0.0 { + continue; + } + + let conditional_weight = prob_to_weight(conditional_p); + + if let (Some(&causal_idx), Some(&affected_idx)) = ( + edge_index_map.get(&causal_key), + edge_index_map.get(&affected_key), + ) { + implied_weights[causal_idx].push(ImpliedWeight { + target_edge_idx: affected_idx, + conditional_weight, + }); + } + } + + Ok(Self { + implied_weights, + num_edges, + }) + } + + /// Check if the table has any correlations. + #[must_use] + pub fn has_correlations(&self) -> bool { + self.implied_weights.iter().any(|v| !v.is_empty()) + } + + /// Number of correlated edge pairs. + #[must_use] + pub fn num_correlations(&self) -> usize { + self.implied_weights.iter().map(Vec::len).sum() + } +} + +/// Parse detector indices from a DEM component string, return edge key. +fn parse_component_edge_key(component: &str) -> Option { + let mut detectors: Vec = Vec::new(); + for token in component.split_whitespace() { + if let Some(d_str) = token.strip_prefix('D') + && let Ok(d) = d_str.parse::() + { + detectors.push(d); + } + } + // Pure observables and hyperedges do not define graph edges. + match detectors.len() { + 1 => Some((detectors[0], u32::MAX)), // Boundary edge + 2 => { + let (a, b) = if detectors[0] <= detectors[1] { + (detectors[0], detectors[1]) + } else { + (detectors[1], detectors[0]) + }; + Some((a, b)) + } + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_bernoulli_xor() { + assert!((bernoulli_xor(0.1, 0.2) - 0.26).abs() < 1e-10); + assert!((bernoulli_xor(0.0, 0.5) - 0.5).abs() < 1e-10); + assert!((bernoulli_xor(0.5, 0.5) - 0.5).abs() < 1e-10); + } + + #[test] + fn test_decomposed_dem() { + // DEM with one decomposed mechanism: D0 D1 ^ D2 D3 + // When D0-D1 is matched, D2-D3 should get a lower weight + let dem = "error(0.01) D0 D1 ^ D2 D3\nerror(0.02) D0 D1\nerror(0.02) D2 D3"; + + let mut edge_map = BTreeMap::new(); + edge_map.insert((0, 1), 0usize); // D0-D1 = edge 0 + edge_map.insert((2, 3), 1usize); // D2-D3 = edge 1 + + let table = CorrelationTable::from_dem_str(dem, &edge_map, 2).unwrap(); + assert!(table.has_correlations()); + + // Edge 0 should have an implied weight for edge 1 + assert!(!table.implied_weights[0].is_empty()); + let iw = &table.implied_weights[0][0]; + assert_eq!(iw.target_edge_idx, 1); + // The conditional weight should be lower than the unconditional + let unconditional_weight = prob_to_weight(0.02 + 0.01); // approximate + assert!(iw.conditional_weight < unconditional_weight); + } + + #[test] + fn test_no_decomposition() { + let dem = "error(0.01) D0 D1\nerror(0.02) D2 D3"; + let mut edge_map = BTreeMap::new(); + edge_map.insert((0, 1), 0usize); + edge_map.insert((2, 3), 1usize); + + let table = CorrelationTable::from_dem_str(dem, &edge_map, 2).unwrap(); + assert!(!table.has_correlations()); + } +} diff --git a/crates/pecos-decoder-core/src/decode_budget.rs b/crates/pecos-decoder-core/src/decode_budget.rs new file mode 100644 index 000000000..140b89582 --- /dev/null +++ b/crates/pecos-decoder-core/src/decode_budget.rs @@ -0,0 +1,245 @@ +// 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. + +//! Decode budget and strategy framework for real-time QEC. +//! +//! Different hardware platforms have different time budgets for decoding: +//! superconducting (~1μs), neutral atoms (~1ms), ion traps (~10ms). +//! The framework selects the best decode strategy based on the budget. +//! +//! # Example +//! +//! ``` +//! use std::time::Duration; +//! +//! use pecos_decoder_core::decode_budget::{DecodeBudget, DetectorRegion}; +//! +//! let distance = 7; +//! let budget = DecodeBudget::from_reaction_time(Duration::from_millis(1), distance); +//! assert!(budget.is_windowed()); +//! assert_eq!(budget.code_distance, distance); +//! +//! let first_round = DetectorRegion { start: 0, end: distance * distance }; +//! assert!(first_round.contains(0)); +//! assert!(!first_round.is_empty()); +//! ``` + +use crate::errors::DecoderError; +use std::time::Duration; + +/// Time and resource budget for decoding. +/// +/// Two timing constraints govern QEC decoding: +/// +/// - **Throughput**: decoder must keep up with syndrome generation to +/// avoid backlog. Measured in time per syndrome round. +/// - **Reaction time**: at feed-forward decision points (T gates, magic +/// state injection), the decoder must produce a correction within a +/// deadline. For Clifford-only circuits, this is unlimited (corrections +/// are metadata applied at the end). +/// +/// The budget also controls the accuracy/latency trade-off via window +/// size and overlap parameters. +#[derive(Debug, Clone)] +pub struct DecodeBudget { + /// Maximum wall-clock time per decode at a decision point. + /// For Clifford circuits this is unlimited; for T gates it's + /// the time between last syndrome and correction application. + pub reaction_time: Duration, + /// Maximum detectors to include in a single decode call. + /// Controls memory usage and decode time. + pub max_window_detectors: usize, + /// Number of overlap rounds at window boundaries. + /// More overlap = better accuracy, more compute. + /// Set to 0 for non-overlapping (fastest, least accurate). + pub overlap_rounds: usize, + /// Code distance (used to scale window sizes). + pub code_distance: usize, +} + +impl DecodeBudget { + /// Unlimited budget: full-circuit decode. Maximum accuracy. + /// + /// Use for Clifford-only circuits (no feed-forward decisions), + /// offline simulation, or any situation where the decoder can + /// take as long as needed. + #[must_use] + pub fn unlimited() -> Self { + Self { + reaction_time: Duration::from_hours(1), + max_window_detectors: usize::MAX, + overlap_rounds: usize::MAX, + code_distance: 0, + } + } + + /// Create a budget from the reaction time at decision points. + /// + /// `reaction_time`: time available between last syndrome and when + /// the correction must be applied (e.g., at T gate injection). + /// + /// Window size and overlap are scaled based on available time: + /// - Very generous (>100ms): unlimited (full-circuit decode) + /// - Generous (1ms - 100ms): large windows, d overlap + /// - Medium (10μs - 1ms): d-round windows, d/2 overlap + /// - Tight (<10μs): minimal windows, no overlap + #[must_use] + pub fn from_reaction_time(reaction_time: Duration, distance: usize) -> Self { + let us = reaction_time.as_micros() as usize; + + let (max_dets, overlap) = if us >= 100_000 { + (usize::MAX, usize::MAX) + } else if us >= 1_000 { + (distance * distance * 4 * distance, distance) + } else if us >= 10 { + (distance * distance * 2 * distance, distance / 2) + } else { + (distance * distance * 2, 0) + }; + + Self { + reaction_time, + max_window_detectors: max_dets, + overlap_rounds: overlap, + code_distance: distance, + } + } + + /// Create a budget with explicit parameters. + #[must_use] + pub fn with_params( + reaction_time: Duration, + max_window_detectors: usize, + overlap_rounds: usize, + code_distance: usize, + ) -> Self { + Self { + reaction_time, + max_window_detectors, + overlap_rounds, + code_distance, + } + } + + /// Whether the budget allows full-circuit decoding (unlimited window). + #[must_use] + pub fn is_unlimited(&self) -> bool { + self.max_window_detectors == usize::MAX && self.overlap_rounds == usize::MAX + } + + /// Whether windowed decoding is needed (non-unlimited budget). + #[must_use] + pub fn is_windowed(&self) -> bool { + !self.is_unlimited() + } +} + +/// A region of detectors in the circuit. +/// +/// Represents a contiguous block of detectors by their global indices. +/// Used by strategies to define decode/commit boundaries. +#[derive(Debug, Clone)] +pub struct DetectorRegion { + /// First global detector index in this region. + pub start: usize, + /// One past the last global detector index. + pub end: usize, +} + +impl DetectorRegion { + /// Number of detectors in this region. + #[must_use] + pub fn len(&self) -> usize { + self.end.saturating_sub(self.start) + } + + /// Whether the region is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.start >= self.end + } + + /// Whether a detector index is in this region. + #[must_use] + pub fn contains(&self, det: usize) -> bool { + det >= self.start && det < self.end + } +} + +/// Strategy for decoding a logical circuit. +/// +/// Strategies implement different decode/commit patterns depending +/// on the time budget. All strategies produce the same type of output +/// (observable correction mask) but with different accuracy/latency +/// trade-offs. +pub trait DecodeStrategy: Send + Sync { + /// Decode a syndrome and return the observable correction mask. + /// + /// The syndrome covers the full circuit. The strategy decides + /// which portion to decode based on its internal state and budget. + fn decode(&mut self, syndrome: &[u8]) -> Result; + + /// Commit corrections for a detector region. + /// + /// After commitment, detectors in this region are excluded from + /// future decode calls. Their corrections are accumulated into + /// the committed observable mask. + fn commit(&mut self, region: &DetectorRegion) -> Result; + + /// Total committed observable correction so far. + fn committed_obs(&self) -> u64; + + /// Reset all state for the next shot. + fn reset(&mut self); +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_budget_unlimited() { + let b = DecodeBudget::unlimited(); + assert!(b.is_unlimited()); + assert!(!b.is_windowed()); + } + + #[test] + fn test_budget_from_reaction_time() { + // Very tight: no overlap + let b = DecodeBudget::from_reaction_time(Duration::from_micros(1), 7); + assert_eq!(b.overlap_rounds, 0); + assert!(b.is_windowed()); + + // Medium: d/2 overlap + let b = DecodeBudget::from_reaction_time(Duration::from_micros(100), 7); + assert_eq!(b.overlap_rounds, 3); + + // Generous: d overlap + let b = DecodeBudget::from_reaction_time(Duration::from_millis(10), 7); + assert_eq!(b.overlap_rounds, 7); + + // Very generous: unlimited + let b = DecodeBudget::from_reaction_time(Duration::from_millis(200), 7); + assert!(b.is_unlimited()); + } + + #[test] + fn test_detector_region() { + let r = DetectorRegion { start: 10, end: 20 }; + assert_eq!(r.len(), 10); + assert!(r.contains(10)); + assert!(r.contains(19)); + assert!(!r.contains(20)); + assert!(!r.contains(9)); + } +} diff --git a/crates/pecos-decoder-core/src/dem.rs b/crates/pecos-decoder-core/src/dem.rs index a04d96216..324a21e50 100644 --- a/crates/pecos-decoder-core/src/dem.rs +++ b/crates/pecos-decoder-core/src/dem.rs @@ -200,6 +200,571 @@ pub mod utils { } } +/// Check matrix representation extracted from a Detector Error Model. +/// +/// Converts a DEM string into the matrices needed by check-matrix-based +/// decoders (BP+OSD, `UnionFind`, `RelayBP`, etc.): +/// +/// - **`check_matrix`** `H[d][m]`: 1 if error mechanism `m` flips detector `d` +/// - **`observable_matrix`** `L[o][m]`: 1 if mechanism `m` flips observable `o` +/// - **`error_priors`** `p[m]`: probability of mechanism `m` +/// +/// # Example +/// +/// ``` +/// use pecos_decoder_core::dem::DemCheckMatrix; +/// +/// let dem = "error(0.01) D0 D1 L0\nerror(0.02) D1 D2"; +/// let dcm = DemCheckMatrix::from_dem_str(dem).unwrap(); +/// assert_eq!(dcm.num_detectors, 3); +/// assert_eq!(dcm.num_observables, 1); +/// assert_eq!(dcm.num_mechanisms, 2); +/// assert_eq!(dcm.error_priors, vec![0.01, 0.02]); +/// ``` +#[derive(Debug, Clone)] +pub struct DemCheckMatrix { + /// Check matrix: rows = detectors, columns = error mechanisms. + pub check_matrix: ndarray::Array2, + /// Observable matrix: rows = observables, columns = error mechanisms. + pub observable_matrix: ndarray::Array2, + /// Error probability per mechanism. + pub error_priors: Vec, + /// Number of detectors (rows of `check_matrix`). + pub num_detectors: usize, + /// Number of observables (rows of `observable_matrix`). + pub num_observables: usize, + /// Number of error mechanisms (columns of both matrices). + pub num_mechanisms: usize, +} + +impl DemCheckMatrix { + /// Parse a DEM string into check matrix form. + /// + /// Each `error(p) D_i D_j ... L_k ...` line becomes one column in the + /// check matrix (for the D entries) and one column in the observable + /// matrix (for the L entries). Decomposed mechanisms (`D0 ^ D1`) are + /// combined by XOR. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if the DEM string is malformed. + pub fn from_dem_str(dem: &str) -> Result { + // First pass: collect mechanisms and find dimensions. + let mut mechanisms: Vec<(f64, Vec, Vec)> = Vec::new(); + let mut max_detector: Option = None; + let mut max_observable: Option = None; + + for line in dem.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if !line.starts_with("error(") { + // Skip non-error lines (detector, logical_observable, etc.) + continue; + } + + // Parse "error(p) D0 D1 ... L0 ..." or "error(p) D0 ^ D1 ..." + let close_paren = line.find(')').ok_or_else(|| { + DecoderError::InvalidConfiguration( + "Missing closing parenthesis in error line".into(), + ) + })?; + let prob_str = &line[6..close_paren]; + let probability: f64 = prob_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!("Invalid probability: {prob_str}")) + })?; + + let tokens_str = &line[close_paren + 1..]; + + // Handle decomposed mechanisms (with ^) by XOR-ing components. + let mut det_set = std::collections::BTreeSet::new(); + let mut obs_set = std::collections::BTreeSet::new(); + + for component in tokens_str.split('^') { + for token in component.split_whitespace() { + if let Some(d_str) = token.strip_prefix('D') { + let d: u32 = d_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!("Invalid detector: {token}")) + })?; + // XOR: toggle membership + 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_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); + } + max_observable = Some(max_observable.map_or(l, |m| m.max(l))); + } + } + } + + let detectors: Vec = det_set.into_iter().collect(); + let observables: Vec = obs_set.into_iter().collect(); + mechanisms.push((probability, detectors, observables)); + } + + let num_detectors = max_detector.map_or(0, |m| m as usize + 1); + let num_observables = max_observable.map_or(0, |m| m as usize + 1); + let num_mechanisms = mechanisms.len(); + + // Build matrices. + let mut check_matrix = ndarray::Array2::::zeros((num_detectors, num_mechanisms)); + let mut observable_matrix = ndarray::Array2::::zeros((num_observables, num_mechanisms)); + let mut error_priors = Vec::with_capacity(num_mechanisms); + + for (col, (prob, detectors, observables)) in mechanisms.iter().enumerate() { + error_priors.push(*prob); + for &d in detectors { + check_matrix[[d as usize, col]] = 1; + } + for &o in observables { + observable_matrix[[o as usize, col]] = 1; + } + } + + Ok(Self { + check_matrix, + observable_matrix, + error_priors, + num_detectors, + num_observables, + num_mechanisms, + }) + } + + /// Compute the observable prediction from a correction vector. + /// + /// Given a binary correction vector (one entry per mechanism, from a + /// check-matrix decoder), returns the observable mask as + /// `observable_matrix @ correction (mod 2)`. + #[must_use] + pub fn observables_from_correction(&self, correction: &[u8]) -> Vec { + let mut obs = vec![0u8; self.num_observables]; + for (o, row) in self.observable_matrix.rows().into_iter().enumerate() { + let mut sum = 0u8; + for (m, &val) in row.iter().enumerate() { + if val != 0 && m < correction.len() && correction[m] != 0 { + sum ^= 1; + } + } + obs[o] = sum; + } + obs + } + + /// Pack observable predictions into a bitmask (u64). + /// + /// Bit `i` is set if observable `i` is predicted to flip. + #[must_use] + pub fn observables_mask_from_correction(&self, correction: &[u8]) -> u64 { + let obs = self.observables_from_correction(correction); + let mut mask = 0u64; + for (i, &v) in obs.iter().enumerate() { + if v != 0 { + mask |= 1 << i; + } + } + mask + } +} + +/// An edge in a matching graph extracted from a DEM. +#[derive(Debug, Clone)] +pub struct MatchingEdge { + /// First detector node (always present). + pub node1: u32, + /// Second detector node, or `None` for a boundary edge. + pub node2: Option, + /// Weight for MWPM: `ln((1-p) / p)`. + pub weight: f64, + /// Observable indices flipped by this error. + pub observables: Vec, + /// Original error probability. + pub probability: f64, + /// Fault mechanism ID (DEM line number). Components from the same + /// decomposed mechanism share the same `fault_id`. + pub fault_id: usize, +} + +/// Matching graph representation extracted from a Detector Error Model. +/// +/// Parses a DEM into edges suitable for MWPM decoders (`PyMatching`, Fusion +/// Blossom). Each graphlike error mechanism (1-2 detectors) becomes one edge. +/// Decomposed mechanisms (`D0 ^ D1`) are split into their components. +/// Hyperedges (3+ detectors after resolution) are skipped with a warning. +/// +/// # Example +/// +/// ``` +/// use pecos_decoder_core::dem::DemMatchingGraph; +/// +/// let dem = "error(0.01) D0 D1 L0\nerror(0.02) D1"; +/// let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); +/// assert_eq!(graph.edges.len(), 2); +/// assert_eq!(graph.num_detectors, 2); +/// ``` +#[derive(Debug, Clone)] +pub struct DemMatchingGraph { + /// Edges in the matching graph. + pub edges: Vec, + /// Number of detectors (max detector ID + 1). + pub num_detectors: usize, + /// Number of observables (max observable ID + 1). + pub num_observables: usize, + /// Number of hyperedges skipped (3+ detectors). + pub skipped_hyperedges: usize, + /// Detector coordinates (from `detector(x,y,t) D_i` declarations). + /// Indexed by detector ID. Empty if no detector declarations in DEM. + pub detector_coords: Vec>>, +} + +impl DemMatchingGraph { + /// Parse a DEM string into a matching graph. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if the DEM string is malformed. + pub fn from_dem_str(dem: &str) -> Result { + let mut edges = Vec::new(); + let mut max_detector: Option = None; + let mut max_observable: Option = None; + let mut skipped = 0usize; + let mut fault_id = 0usize; + + for line in dem.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') || !line.starts_with("error(") { + continue; + } + + let close_paren = line.find(')').ok_or_else(|| { + DecoderError::InvalidConfiguration("Missing closing parenthesis".into()) + })?; + let prob_str = &line[6..close_paren]; + let probability: f64 = prob_str.parse().map_err(|_| { + DecoderError::InvalidConfiguration(format!("Invalid probability: {prob_str}")) + })?; + + if probability <= 0.0 { + continue; + } + + let weight = if probability < 1.0 { + ((1.0 - probability) / probability).ln() + } else { + 0.0 + }; + + let tokens_str = &line[close_paren + 1..]; + + // For decomposed mechanisms (with ^), each component is a separate edge. + // For non-decomposed mechanisms, there's one component. + let components: Vec<&str> = tokens_str.split('^').collect(); + + for component in &components { + let mut detectors = Vec::new(); + let mut observables = Vec::new(); + + for token in component.split_whitespace() { + 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_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))); + } + } + + match detectors.len() { + 0 => {} // Pure observable error, skip + 1 => { + edges.push(MatchingEdge { + node1: detectors[0], + node2: None, // boundary + weight, + observables, + probability, + fault_id, + }); + } + 2 => { + edges.push(MatchingEdge { + node1: detectors[0], + node2: Some(detectors[1]), + weight, + observables, + probability, + fault_id, + }); + } + _ => { + skipped += 1; + } + } + } + fault_id += 1; + } + + let num_detectors = max_detector.map_or(0, |m| m as usize + 1); + let num_observables = max_observable.map_or(0, |m| m as usize + 1); + + let edges = Self::merge_parallel_edges(edges); + + // Parse detector coordinates + let coords = parse_detector_coords(dem); + let mut detector_coords = vec![None; num_detectors]; + for dc in coords { + if (dc.id as usize) < num_detectors { + detector_coords[dc.id as usize] = Some(dc.coords); + } + } + + Ok(Self { + edges, + num_detectors, + num_observables, + skipped_hyperedges: skipped, + detector_coords, + }) + } + + /// Merge edges with independent fault-ID-aware probability combination. + /// + /// Components from the same fault mechanism (same `fault_id`) that land on + /// the same edge pair are NOT merged -- they're part of one correlated event. + /// Components from different fault mechanisms (different `fault_id`) are + /// combined using: `p_combined = p_a*(1-p_b) + p_b*(1-p_a)`. + /// + /// This matches `PyMatching`'s "independent" merge strategy with fault ID tracking. + pub(crate) fn merge_parallel_edges(edges: Vec) -> Vec { + use std::collections::BTreeMap; + + type EdgeKey = (u32, Option); + + // First, deduplicate: for each (edge_key, fault_id), keep only one entry. + // Multiple components from the same fault_id on the same edge just confirm + // that the fault affects this edge -- don't double-count the probability. + let mut per_fault: BTreeMap<(EdgeKey, usize), MatchingEdge> = BTreeMap::new(); + + for edge in edges { + let key = match edge.node2 { + Some(n2) if edge.node1 > n2 => (n2, Some(edge.node1)), + _ => (edge.node1, edge.node2), + }; + let fault_key = (key, edge.fault_id); + // First occurrence of this (edge, fault_id) wins + per_fault.entry(fault_key).or_insert(MatchingEdge { + node1: key.0, + node2: key.1, + ..edge + }); + } + + // Now merge across different fault_ids for the same edge pair + let mut merged: BTreeMap = BTreeMap::new(); + + for ((edge_key, _fault_id), edge) in per_fault { + if let Some(existing) = merged.get_mut(&edge_key) { + // Independent combination: p_ab = p_a*(1-p_b) + p_b*(1-p_a) + let p_a = existing.probability; + let p_b = edge.probability; + let p_combined = p_a * (1.0 - p_b) + p_b * (1.0 - p_a); + existing.probability = p_combined; + existing.weight = if p_combined > 0.0 && p_combined < 1.0 { + ((1.0 - p_combined) / p_combined).ln() + } else if p_combined >= 1.0 { + 0.0 + } else { + 1e6 + }; + + // Keep the first edge's observables (matching PyMatching's + // INDEPENDENT strategy). If observables differ between parallel + // edges on the same node pair, the code has distance 2. + } else { + merged.insert(edge_key, edge); + } + } + + merged.into_values().collect() + } +} + +/// Generic wrapper that combines any [`Decoder`] with a [`DemCheckMatrix`] +/// to implement [`ObservableDecoder`]. +/// +/// This is the proper way to use check-matrix decoders (BP+OSD, `UnionFind`, +/// `RelayBP`, etc.) in a sample+decode loop. The wrapper: +/// 1. Passes the syndrome to the inner decoder +/// 2. Gets back a correction vector +/// 3. Multiplies by the observable matrix to get the observable prediction +/// +/// # Example +/// +/// ``` +/// use ndarray::ArrayView1; +/// +/// use pecos_decoder_core::{ +/// CheckMatrixObservableDecoder, Decoder, DecoderError, DecodingResultTrait, DemCheckMatrix, +/// ObservableDecoder, +/// }; +/// +/// struct CorrectionResult { +/// correction: Vec, +/// } +/// +/// impl DecodingResultTrait for CorrectionResult { +/// fn is_successful(&self) -> bool { +/// true +/// } +/// +/// fn correction(&self) -> &[u8] { +/// &self.correction +/// } +/// } +/// +/// struct FirstMechanismDecoder { +/// checks: usize, +/// bits: usize, +/// } +/// +/// impl Decoder for FirstMechanismDecoder { +/// type Result = CorrectionResult; +/// type Error = DecoderError; +/// +/// fn decode(&mut self, input: &ArrayView1) -> Result { +/// assert_eq!(input.len(), self.checks); +/// let mut correction = vec![0; self.bits]; +/// correction[0] = 1; +/// Ok(CorrectionResult { correction }) +/// } +/// +/// fn check_count(&self) -> usize { +/// self.checks +/// } +/// +/// fn bit_count(&self) -> usize { +/// self.bits +/// } +/// } +/// +/// let dem_str = "error(0.01) D0 L0\nerror(0.02) D0"; +/// let dcm = DemCheckMatrix::from_dem_str(dem_str).unwrap(); +/// let inner_decoder = FirstMechanismDecoder { +/// checks: dcm.num_detectors, +/// bits: dcm.num_mechanisms, +/// }; +/// let mut decoder = CheckMatrixObservableDecoder::new(inner_decoder, dcm); +/// +/// let mask = decoder.decode_to_observables(&[1]).unwrap(); +/// assert_eq!(mask, 0b1); +/// ``` +pub struct CheckMatrixObservableDecoder { + /// The inner check-matrix decoder. + pub decoder: D, + /// The DEM check matrix (holds observable matrix for prediction). + pub dem: DemCheckMatrix, + /// Reusable syndrome buffer (avoids per-shot ndarray allocation). + syndrome_arr: ndarray::Array1, +} + +impl CheckMatrixObservableDecoder { + /// Create a new wrapper from a decoder and its DEM check matrix. + pub fn new(decoder: D, dem: DemCheckMatrix) -> Self { + let len = dem.num_detectors; + Self { + decoder, + dem, + syndrome_arr: ndarray::Array1::zeros(len), + } + } +} + +impl super::ObservableDecoder for CheckMatrixObservableDecoder +where + D: super::Decoder, +{ + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + use super::DecodingResultTrait; + + // Copy syndrome into reusable buffer (no allocation after first call) + let len = syndrome.len(); + if self.syndrome_arr.len() != len { + self.syndrome_arr = ndarray::Array1::zeros(len); + } + self.syndrome_arr + .as_slice_mut() + .unwrap() + .copy_from_slice(syndrome); + let result = self + .decoder + .decode(&self.syndrome_arr.view()) + .map_err(|e| DecoderError::DecodingFailed(e.to_string()))?; + + let correction = result.correction(); + Ok(self.dem.observables_mask_from_correction(correction)) + } +} + +/// Detector coordinate parsed from a DEM `detector(x, y, t) D_i` line. +#[derive(Debug, Clone)] +pub struct DetectorCoord { + /// Detector ID. + pub id: u32, + /// Coordinates (typically x, y, t for surface codes). + pub coords: Vec, +} + +/// Parse detector coordinates from a DEM string. +/// +/// Returns a list of `DetectorCoord` for each `detector(...)` declaration. +#[must_use] +pub fn parse_detector_coords(dem: &str) -> Vec { + let mut result = Vec::new(); + for line in dem.lines() { + let line = line.trim(); + if !line.starts_with("detector(") { + continue; + } + if let Some(close) = line.find(')') { + let coord_str = &line[9..close]; + let coords: Vec = coord_str + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + // Find D_i after the closing paren + let rest = &line[close + 1..]; + for token in rest.split_whitespace() { + if let Some(d_str) = token.strip_prefix('D') + && let Ok(id) = d_str.parse::() + { + result.push(DetectorCoord { + id, + coords: coords.clone(), + }); + } + } + } + } + result +} + /// Information about a detector error model #[derive(Debug, Clone, PartialEq)] pub struct DemInfo { @@ -301,6 +866,63 @@ mod tests { assert_eq!(observables, 2); // L0 and 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"; + let dcm = DemCheckMatrix::from_dem_str(dem).unwrap(); + + assert_eq!(dcm.num_detectors, 3); + assert_eq!(dcm.num_observables, 1); + assert_eq!(dcm.num_mechanisms, 3); + assert_eq!(dcm.error_priors, vec![0.01, 0.02, 0.03]); + + // Check matrix: mechanism 0 -> D0,D1; mechanism 1 -> D1,D2; mechanism 2 -> D0,D2 + assert_eq!(dcm.check_matrix[[0, 0]], 1); // D0, mech 0 + assert_eq!(dcm.check_matrix[[1, 0]], 1); // D1, mech 0 + assert_eq!(dcm.check_matrix[[2, 0]], 0); // D2, mech 0 + assert_eq!(dcm.check_matrix[[0, 1]], 0); // D0, mech 1 + assert_eq!(dcm.check_matrix[[1, 1]], 1); // D1, mech 1 + assert_eq!(dcm.check_matrix[[2, 1]], 1); // D2, mech 1 + + // Observable matrix: mechanism 0 -> L0; mechanism 1 -> none; mechanism 2 -> L0 + assert_eq!(dcm.observable_matrix[[0, 0]], 1); + assert_eq!(dcm.observable_matrix[[0, 1]], 0); + assert_eq!(dcm.observable_matrix[[0, 2]], 1); + } + + #[test] + fn test_dem_check_matrix_observables_from_correction() { + let dem = "error(0.01) D0 L0\nerror(0.01) D1 L1\nerror(0.01) D0 D1 L0 L1"; + let dcm = DemCheckMatrix::from_dem_str(dem).unwrap(); + + // Correction activates mechanism 0 -> L0 flips + assert_eq!(dcm.observables_mask_from_correction(&[1, 0, 0]), 0b01); + // Correction activates mechanism 2 -> L0 and L1 flip + assert_eq!(dcm.observables_mask_from_correction(&[0, 0, 1]), 0b11); + // Correction activates mechanisms 0 and 2 -> L0 xor L0 = 0, L1 flips + assert_eq!(dcm.observables_mask_from_correction(&[1, 0, 1]), 0b10); + } + + #[test] + fn test_dem_check_matrix_decomposed() { + // Decomposed mechanism: D0 ^ D1 means XOR + let dem = "error(0.01) D0 D1 ^ D1 D2"; + let dcm = DemCheckMatrix::from_dem_str(dem).unwrap(); + + // D1 appears in both components -> XOR cancels it + assert_eq!(dcm.check_matrix[[0, 0]], 1); // D0 + assert_eq!(dcm.check_matrix[[1, 0]], 0); // D1 cancels + assert_eq!(dcm.check_matrix[[2, 0]], 1); // D2 + } + + #[test] + fn test_dem_check_matrix_empty() { + let dem = ""; + let dcm = DemCheckMatrix::from_dem_str(dem).unwrap(); + assert_eq!(dcm.num_mechanisms, 0); + assert_eq!(dcm.num_detectors, 0); + } + #[test] fn test_dem_config_builder() { let config = DemConfigBuilder::new() diff --git a/crates/pecos-decoder-core/src/ensemble.rs b/crates/pecos-decoder-core/src/ensemble.rs new file mode 100644 index 000000000..c969f22c3 --- /dev/null +++ b/crates/pecos-decoder-core/src/ensemble.rs @@ -0,0 +1,270 @@ +// 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. + +//! Multi-decoder ensemble with per-observable majority vote. +//! +//! Runs multiple decoders on the same syndrome, then combines their +//! predictions via (optionally weighted) majority vote on each +//! observable bit independently. + +use crate::ObservableDecoder; +use crate::errors::DecoderError; + +/// Voting strategy for combining decoder predictions. +#[derive(Debug, Clone)] +pub enum VotingStrategy { + /// Each decoder gets one vote. Ties go to 0 (no flip). + Majority, + /// Each decoder gets a weight. Higher weight = more influence. + /// Ties (equal total weight for 0 and 1) go to 0. + Weighted(Vec), +} + +/// Multi-decoder ensemble that votes on observable predictions. +/// +/// Each member decoder runs independently on the same syndrome, +/// then the ensemble combines their observable masks via majority +/// vote (per bit). +pub struct EnsembleDecoder { + decoders: Vec>, + strategy: VotingStrategy, + /// Reusable buffer for collecting predictions. + predictions: Vec, +} + +impl EnsembleDecoder { + /// Create an ensemble with uniform (majority) voting. + #[must_use] + pub fn new(decoders: Vec>) -> Self { + let n = decoders.len(); + Self { + decoders, + strategy: VotingStrategy::Majority, + predictions: Vec::with_capacity(n), + } + } + + /// Create an ensemble with weighted voting. + /// + /// # Panics + /// + /// Panics if `weights.len() != decoders.len()`. + #[must_use] + pub fn with_weights(decoders: Vec>, weights: Vec) -> Self { + assert_eq!( + decoders.len(), + weights.len(), + "number of weights must match number of decoders" + ); + let n = decoders.len(); + Self { + decoders, + strategy: VotingStrategy::Weighted(weights), + predictions: Vec::with_capacity(n), + } + } + + /// Number of decoders in the ensemble. + #[must_use] + pub fn len(&self) -> usize { + self.decoders.len() + } + + /// Whether the ensemble has no decoders. + #[must_use] + pub fn is_empty(&self) -> bool { + self.decoders.is_empty() + } +} + +impl ObservableDecoder for EnsembleDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + if self.decoders.is_empty() { + return Ok(0); + } + + // Collect predictions from all decoders. + self.predictions.clear(); + for decoder in &mut self.decoders { + self.predictions + .push(decoder.decode_to_observables(syndrome)?); + } + + // Vote on each observable bit independently. + let mut result = 0u64; + for bit in 0..64 { + let mask = 1u64 << bit; + + // Check if any decoder cares about this bit. + let any_set = self.predictions.iter().any(|&p| p & mask != 0); + if !any_set { + continue; + } + + let vote_for_flip = match &self.strategy { + VotingStrategy::Majority => { + let count = self.predictions.iter().filter(|&&p| p & mask != 0).count(); + // Strict majority: more than half must vote flip. + count * 2 > self.decoders.len() + } + VotingStrategy::Weighted(weights) => { + let mut weight_flip = 0.0; + let mut weight_no_flip = 0.0; + for (i, &pred) in self.predictions.iter().enumerate() { + if pred & mask != 0 { + weight_flip += weights[i]; + } else { + weight_no_flip += weights[i]; + } + } + weight_flip > weight_no_flip + } + }; + + if vote_for_flip { + result |= mask; + } + } + + Ok(result) + } +} + +/// Thread-safe ensemble decoder that decodes K members in parallel using rayon. +/// +/// Same majority-vote logic as `EnsembleDecoder` but runs all members +/// concurrently. Requires inner decoders to be `Send`. +pub struct ParallelEnsembleDecoder { + decoders: Vec>, +} + +impl ParallelEnsembleDecoder { + /// Create a parallel ensemble with majority voting. + #[must_use] + pub fn new(decoders: Vec>) -> Self { + Self { decoders } + } + + /// Number of decoders. + #[must_use] + pub fn len(&self) -> usize { + self.decoders.len() + } + + /// Whether the ensemble is empty. + #[must_use] + pub fn is_empty(&self) -> bool { + self.decoders.is_empty() + } +} + +impl ObservableDecoder for ParallelEnsembleDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + use rayon::prelude::*; + + if self.decoders.is_empty() { + return Ok(0); + } + + // Decode all members in parallel. + let predictions: Result, DecoderError> = self + .decoders + .par_iter_mut() + .map(|decoder| decoder.decode_to_observables(syndrome)) + .collect(); + let predictions = predictions?; + + // Majority vote. + let half = predictions.len() / 2; + let mut result = 0u64; + for bit in 0..64 { + let mask = 1u64 << bit; + let count = predictions.iter().filter(|&&p| p & mask != 0).count(); + if count > half { + result |= mask; + } + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + /// Fake decoder that always returns a fixed observable mask. + struct FixedDecoder(u64); + + impl ObservableDecoder for FixedDecoder { + fn decode_to_observables(&mut self, _syndrome: &[u8]) -> Result { + Ok(self.0) + } + } + + #[test] + fn test_majority_unanimous() { + let decoders: Vec> = vec![ + Box::new(FixedDecoder(0b101)), + Box::new(FixedDecoder(0b101)), + Box::new(FixedDecoder(0b101)), + ]; + let mut ensemble = EnsembleDecoder::new(decoders); + assert_eq!(ensemble.decode_to_observables(&[]).unwrap(), 0b101); + } + + #[test] + fn test_majority_split() { + let decoders: Vec> = vec![ + Box::new(FixedDecoder(0b11)), + Box::new(FixedDecoder(0b01)), + Box::new(FixedDecoder(0b01)), + ]; + let mut ensemble = EnsembleDecoder::new(decoders); + // Bit 0: 3/3 vote flip -> flip. Bit 1: 1/3 vote flip -> no flip. + assert_eq!(ensemble.decode_to_observables(&[]).unwrap(), 0b01); + } + + #[test] + fn test_majority_tie_goes_to_zero() { + // With 2 decoders, need >50% for flip. 1/2 is not >50%. + let decoders: Vec> = + vec![Box::new(FixedDecoder(1)), Box::new(FixedDecoder(0))]; + let mut ensemble = EnsembleDecoder::new(decoders); + assert_eq!(ensemble.decode_to_observables(&[]).unwrap(), 0); + } + + #[test] + fn test_weighted_vote() { + let decoders: Vec> = vec![ + Box::new(FixedDecoder(1)), // votes flip, weight 3.0 + Box::new(FixedDecoder(0)), // votes no flip, weight 1.0 + Box::new(FixedDecoder(0)), // votes no flip, weight 1.0 + ]; + let mut ensemble = EnsembleDecoder::with_weights(decoders, vec![3.0, 1.0, 1.0]); + // Weight for flip: 3.0, weight for no flip: 2.0. Flip wins. + assert_eq!(ensemble.decode_to_observables(&[]).unwrap(), 1); + } + + #[test] + fn test_empty_ensemble() { + let mut ensemble = EnsembleDecoder::new(vec![]); + assert_eq!(ensemble.decode_to_observables(&[]).unwrap(), 0); + assert!(ensemble.is_empty()); + } + + #[test] + fn test_single_decoder() { + let decoders: Vec> = vec![Box::new(FixedDecoder(42))]; + let mut ensemble = EnsembleDecoder::new(decoders); + assert_eq!(ensemble.decode_to_observables(&[]).unwrap(), 42); + } +} diff --git a/crates/pecos-decoder-core/src/erasure.rs b/crates/pecos-decoder-core/src/erasure.rs new file mode 100644 index 000000000..0fb661a96 --- /dev/null +++ b/crates/pecos-decoder-core/src/erasure.rs @@ -0,0 +1,48 @@ +// 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. + +//! Erasure-aware observable decoder for neutral atom QEC. +//! +//! Neutral atoms have a dominant erasure channel: atom loss is detectable +//! via fluorescence, giving the decoder side-channel information about +//! which qubits were lost. This raises the surface code threshold from +//! ~1% to ~4%. +//! +//! The decoder receives: +//! - A syndrome (detection events) +//! - A list of erased qubit/edge indices (known error locations) +//! +//! Erased edges are set to zero weight (certain error) during matching, +//! guiding the decoder to incorporate them into the correction. + +use crate::errors::DecoderError; + +/// Trait for decoders that can handle erasure information alongside the syndrome. +/// +/// For neutral atoms, erasures come from atom loss detection. The decoder +/// sets erased edge weights to zero (guaranteed error) and finds the +/// minimum-weight correction incorporating the known erasures. +pub trait ObservableErasureDecoder { + /// Decode a syndrome with erasure side-channel information. + /// + /// `erasure_edges`: indices of edges (error mechanisms) known to have + /// fired. These are set to zero weight during matching. + /// + /// # Errors + /// + /// Returns `DecoderError` if decoding fails. + fn decode_with_erasures( + &mut self, + syndrome: &[u8], + erasure_edges: &[usize], + ) -> Result; +} diff --git a/crates/pecos-decoder-core/src/ghost_protocol.rs b/crates/pecos-decoder-core/src/ghost_protocol.rs new file mode 100644 index 000000000..44cba87cc --- /dev/null +++ b/crates/pecos-decoder-core/src/ghost_protocol.rs @@ -0,0 +1,349 @@ +// 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. + +//! Ghost protocol for modular per-qubit decoding across transversal gates. +//! +//! Based on Turner et al. (arXiv:2505.23567): decomposes order-3 +//! hyperedges from transversal CNOT into per-qubit ghost edges. +//! Each qubit's decoder runs independently with sparse message passing, +//! enabling scalable decoding of many logical qubits. +//! +//! # Algorithm +//! +//! 1. Decompose cross-qubit hyperedges into ghost edge + ghost singleton +//! 2. Each qubit decoded independently with ghost edges in its graph +//! 3. If matching includes a ghost edge: refine syndrome, message partner +//! 4. Partner flips its ghost singleton defect, re-decodes +//! 5. Iterate until convergence (no new ghost edges detected) +//! +//! # References +//! +//! - Turner et al. "Scalable decoding protocols for fast transversal +//! logic in the surface code" (arXiv:2505.23567, PRX Quantum 2026) +//! - Cain et al. "Fast correlated decoding of transversal logical +//! algorithms" (arXiv:2505.13587) + +/// A ghost edge: fragment of a cross-qubit hyperedge. +/// +/// When a measurement error before a transversal CNOT creates a +/// 3-detector hyperedge spanning two qubits, it decomposes into: +/// - `ghost_edge`: time-like edge within one qubit (connects two +/// detectors on the same qubit across the gate boundary) +/// - `ghost_singleton`: boundary edge on the partner qubit (flips +/// one detector on the partner) +#[derive(Debug, Clone)] +pub struct GhostEdge { + /// Qubit (patch) that owns this ghost edge. + pub owner_qubit: usize, + /// Local detector index for the first endpoint. + pub det_a: u32, + /// Local detector index for the second endpoint. + pub det_b: u32, + /// Partner qubit that owns the ghost singleton. + pub partner_qubit: usize, + /// Local detector index of the ghost singleton on the partner. + pub partner_det: u32, + /// Edge weight (log-likelihood ratio). + pub weight: f64, +} + +/// Ghost protocol state for iterative decoding. +/// +/// Tracks ghost edges, messages between per-qubit decoders, and +/// syndrome refinement state. Created once per circuit structure, +/// reused across shots. +pub struct GhostProtocolState { + /// Ghost edges grouped by owner qubit. + pub ghost_edges: Vec>, + /// Number of logical qubits (patches). + pub num_qubits: usize, + /// Maximum iterations before giving up. + pub max_iterations: usize, +} + +impl GhostProtocolState { + /// Create ghost protocol state for a circuit. + /// + /// `ghost_edges`: all ghost edges, will be grouped by owner qubit. + /// `num_qubits`: number of logical qubits. + #[must_use] + pub fn new(ghost_edges: Vec, num_qubits: usize) -> Self { + let mut grouped = vec![Vec::new(); num_qubits]; + for ge in ghost_edges { + if ge.owner_qubit < num_qubits { + grouped[ge.owner_qubit].push(ge); + } + } + Self { + ghost_edges: grouped, + num_qubits, + max_iterations: 10, + } + } + + /// Number of ghost edges for a qubit. + #[must_use] + pub fn num_ghost_edges(&self, qubit: usize) -> usize { + self.ghost_edges.get(qubit).map_or(0, std::vec::Vec::len) + } + + /// Total ghost edges across all qubits. + #[must_use] + pub fn total_ghost_edges(&self) -> usize { + self.ghost_edges.iter().map(std::vec::Vec::len).sum() + } +} + +/// Message from one qubit's decoder to another. +/// +/// When a ghost edge is detected in the matching, the owner sends +/// this message to the partner: "flip your ghost singleton defect." +#[derive(Debug, Clone)] +pub struct GhostMessage { + /// Target qubit. + pub target_qubit: usize, + /// Detector to flip on the target. + pub flip_detector: u32, +} + +/// Extract ghost edges from a DEM at transversal CNOT boundaries. +/// +/// Identifies 3-detector mechanisms where: +/// - 2 detectors are on one qubit (the ghost edge endpoints) +/// - 1 detector is on another qubit (the ghost singleton) +/// +/// The qubit assignment comes from the detector's spatial coordinates +/// and the stabilizer coordinate map. +/// +/// Returns the ghost edges for the ghost protocol. +/// Extract ghost edges from a DEM by identifying 3-detector hyperedges +/// and decomposing them by qubit ownership. +/// +/// For each 3-detector mechanism where 2 detectors are on one qubit +/// and 1 is on another: +/// - `ghost_edge` = the 2-detector pair (within one qubit) +/// - `ghost_singleton` = the lone detector (on the partner qubit) +#[must_use] +pub fn extract_ghost_edges_from_dem( + dem_str: &str, + stab_coords: &crate::observable_subgraph::StabCoords, +) -> Vec { + use crate::observable_subgraph::classify_detector; + use std::collections::BTreeMap; + + // Parse detector coordinates + let det_coords = crate::dem::parse_detector_coords(dem_str); + let mut coord_map: BTreeMap> = BTreeMap::new(); + for dc in &det_coords { + coord_map.insert(dc.id as usize, dc.coords.clone()); + } + + // Classify detectors by qubit + let mut det_qubit: BTreeMap = BTreeMap::new(); + for (&d, coords) in &coord_map { + if coords.len() >= 2 + && let Some(group) = classify_detector(coords[0], coords[1], stab_coords) + { + det_qubit.insert(d, group.qubit_idx); + } + } + + let mut ghost_edges = Vec::new(); + + for line in dem_str.lines() { + let line = line.trim(); + if !line.starts_with("error(") { + continue; + } + + let Some(close) = line.find(')') else { + continue; + }; + + let prob: f64 = match line[6..close].parse() { + Ok(p) => p, + Err(_) => continue, + }; + + let mut dets = Vec::new(); + for token in line[close + 1..].split_whitespace() { + if let Some(d_str) = token.strip_prefix('D') + && let Ok(d) = d_str.parse::() + { + dets.push(d); + } + } + + if dets.len() != 3 { + continue; + } + + let qs: Vec> = dets.iter().map(|d| det_qubit.get(d).copied()).collect(); + + if qs.iter().any(std::option::Option::is_none) { + continue; + } + let qs: Vec = qs.into_iter().flatten().collect(); + + let weight = if prob < 1.0 && prob > 0.0 { + ((1.0 - prob) / prob).ln() + } else { + 0.0 + }; + + // Decompose: 2 on one qubit (ghost edge), 1 on another (singleton) + let decompose = + |owner_q: usize, a: usize, b: usize, partner_q: usize, c: usize| GhostEdge { + owner_qubit: owner_q, + det_a: a as u32, + det_b: b as u32, + partner_qubit: partner_q, + partner_det: c as u32, + weight, + }; + + if qs[0] == qs[1] && qs[0] != qs[2] { + ghost_edges.push(decompose(qs[0], dets[0], dets[1], qs[2], dets[2])); + } else if qs[0] == qs[2] && qs[0] != qs[1] { + ghost_edges.push(decompose(qs[0], dets[0], dets[2], qs[1], dets[1])); + } else if qs[1] == qs[2] && qs[1] != qs[0] { + ghost_edges.push(decompose(qs[1], dets[1], dets[2], qs[0], dets[0])); + } + } + + ghost_edges +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_ghost_protocol_state() { + let edges = vec![ + GhostEdge { + owner_qubit: 0, + det_a: 5, + det_b: 10, + partner_qubit: 1, + partner_det: 7, + weight: 3.0, + }, + GhostEdge { + owner_qubit: 1, + det_a: 3, + det_b: 8, + partner_qubit: 0, + partner_det: 12, + weight: 3.0, + }, + ]; + + let state = GhostProtocolState::new(edges, 2); + assert_eq!(state.num_qubits, 2); + assert_eq!(state.num_ghost_edges(0), 1); + assert_eq!(state.num_ghost_edges(1), 1); + assert_eq!(state.total_ghost_edges(), 2); + } + + #[test] + fn test_empty_ghost_protocol() { + let state = GhostProtocolState::new(Vec::new(), 4); + assert_eq!(state.total_ghost_edges(), 0); + } + + #[test] + fn test_extract_ghost_edges_from_synthetic_dem() { + use crate::observable_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). + let stab_coords = vec![ + QubitStabCoords { + x_positions: vec![(1.0, 1.0)], + z_positions: vec![(3.0, 1.0)], + }, + QubitStabCoords { + x_positions: vec![(7.0, 1.0)], + z_positions: vec![(9.0, 1.0)], + }, + ]; + + // DEM with: + // - D0 at (1,1,0) -> qubit 0 (X-stab) + // - D1 at (3,1,0) -> qubit 0 (Z-stab) + // - D2 at (7,1,0) -> qubit 1 (X-stab) + // - 3-body error: D0 D1 D2 (2 on qubit 0, 1 on qubit 1) + // - 2-body error: D0 D1 (same qubit, no ghost edge) + let dem = "\ + detector(1, 1, 0) D0\n\ + detector(3, 1, 0) D1\n\ + detector(7, 1, 0) D2\n\ + error(0.01) D0 D1 D2\n\ + error(0.02) D0 D1\n"; + + let edges = extract_ghost_edges_from_dem(dem, &stab_coords); + + // Should extract exactly 1 ghost edge from the 3-body mechanism + assert_eq!(edges.len(), 1); + + let e = &edges[0]; + assert_eq!(e.owner_qubit, 0); // D0 and D1 are on qubit 0 + assert_eq!(e.det_a, 0); + assert_eq!(e.det_b, 1); + assert_eq!(e.partner_qubit, 1); // D2 is on qubit 1 + assert_eq!(e.partner_det, 2); + + // Weight should be ln((1 - 0.01) / 0.01) ≈ 4.595 + assert!((e.weight - 4.595).abs() < 0.01); + } + + #[test] + fn test_extract_no_ghost_edges_graphlike_dem() { + use crate::observable_subgraph::QubitStabCoords; + + let stab_coords = vec![QubitStabCoords { + x_positions: vec![(1.0, 1.0)], + z_positions: vec![(3.0, 1.0)], + }]; + + // Only 2-body errors -> no ghost edges + let dem = "\ + detector(1, 1, 0) D0\n\ + detector(3, 1, 0) D1\n\ + error(0.01) D0 D1\n\ + error(0.005) D0\n"; + + let edges = extract_ghost_edges_from_dem(dem, &stab_coords); + assert_eq!(edges.len(), 0); + } + + #[test] + fn test_extract_three_same_qubit_no_ghost() { + use crate::observable_subgraph::QubitStabCoords; + + let stab_coords = vec![QubitStabCoords { + x_positions: vec![(1.0, 1.0), (1.0, 3.0)], + z_positions: vec![(3.0, 1.0)], + }]; + + // All 3 detectors on same qubit -> no decomposition + let dem = "\ + detector(1, 1, 0) D0\n\ + detector(1, 3, 0) D1\n\ + detector(3, 1, 0) D2\n\ + error(0.01) D0 D1 D2\n"; + + let edges = extract_ghost_edges_from_dem(dem, &stab_coords); + assert_eq!(edges.len(), 0); + } +} diff --git a/crates/pecos-decoder-core/src/k_mwpm.rs b/crates/pecos-decoder-core/src/k_mwpm.rs new file mode 100644 index 000000000..7814059f7 --- /dev/null +++ b/crates/pecos-decoder-core/src/k_mwpm.rs @@ -0,0 +1,276 @@ +// 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. + +//! K-MWPM decoder: enumerate K lowest-weight matchings, majority vote. +//! +//! Based on the Chegireddy-Hamacher algorithm adapted for QEC by +//! Mao Lin (Phys. Rev. A 112, 042436, 2025; arXiv:2510.06531). +//! +//! Builds a "decoding tree" where each branch removes one matched edge +//! from the parent and re-matches. The K lowest-weight matchings give +//! K observable predictions; majority vote selects the final answer. +//! +//! Works with any `MatchingDecoder` backend (UF, Fusion Blossom). + +use crate::ObservableDecoder; +use crate::correlated_decoder::EdgeTrackingDecoder; +use crate::errors::DecoderError; +use std::cmp::Reverse; +use std::collections::BinaryHeap; + +/// Configuration for the K-MWPM decoder. +#[derive(Debug, Clone, Copy)] +pub struct KMwpmConfig { + /// Number of matchings to enumerate. Default 10. + pub k: usize, +} + +impl Default for KMwpmConfig { + fn default() -> Self { + Self { k: 10 } + } +} + +/// One node in the decoding tree. +struct TreeNode { + /// Matched edge indices from the MWPM solve. + matched_edges: Vec, + /// Edges that were removed (set to infinity) for this branch. + removed_edges: Vec, + /// Syndrome modifications: detector indices to flip. + flipped_detectors: Vec, + /// Index in `matched_edges` up to which edges are "committed" (removed). + commit_idx: usize, +} + +/// K-MWPM decoder wrapping any `EdgeTrackingDecoder`. +pub struct KMwpmDecoder { + decoder: D, + config: KMwpmConfig, + num_edges: usize, +} + +impl KMwpmDecoder { + /// Create from an existing decoder. + pub fn new(decoder: D, config: KMwpmConfig) -> Self { + let num_edges = decoder.num_edges(); + Self { + decoder, + config, + num_edges, + } + } +} + +impl ObservableDecoder for KMwpmDecoder { + fn decode_to_observables(&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); + } + + // Collect K matchings via decoding tree. + let mut predictions: Vec = vec![obs1]; + + // Priority queue of tree nodes to expand (by weight, lowest first). + let mut pq: BinaryHeap<(Reverse, usize)> = BinaryHeap::new(); + let mut nodes: Vec = Vec::new(); + + let root = TreeNode { + matched_edges: edges1, + removed_edges: Vec::new(), + flipped_detectors: Vec::new(), + commit_idx: 0, + }; + nodes.push(root); + pq.push((Reverse(0), 0)); // Weight 0 for expansion priority (children will have real weights) + + while predictions.len() < k { + let Some((_, node_idx)) = pq.pop() else { + break; // No more candidates + }; + + // Expand this node: for each matched edge from commit_idx onward, + // create a child that removes that edge and re-matches. + let (matched_edges, removed_edges, flipped_detectors, commit_idx) = { + let node = &nodes[node_idx]; + ( + node.matched_edges.clone(), + node.removed_edges.clone(), + node.flipped_detectors.clone(), + node.commit_idx, + ) + }; + + for j in commit_idx..matched_edges.len() { + // Build modified weights: removed edges get infinite weight. + let mut weights = vec![0.0f64; self.num_edges]; + // Start with original weights for all edges. + for (e, weight) in weights.iter_mut().enumerate().take(self.num_edges) { + *weight = self.decoder.edge_weight(e); + } + // Remove previously removed edges. + for &re in &removed_edges { + weights[re] = 1e10; + } + // Remove edges e_{commit_idx}..e_j (inclusive). + for &re in &matched_edges[commit_idx..=j] { + weights[re] = 1e10; + } + + // Build modified syndrome: flip endpoints of committed edges. + let mut syn_mod = syndrome.to_vec(); + for &det in &flipped_detectors { + if det < syn_mod.len() { + syn_mod[det] ^= 1; + } + } + // Also flip endpoints of edges commit_idx..j-1 (newly committed). + let num_det = self.decoder.num_detectors(); + for &edge_idx in &matched_edges[commit_idx..j] { + let n1 = self.decoder.edge_node1(edge_idx) as usize; + let n2 = self.decoder.edge_node2(edge_idx) as usize; + if n1 < syn_mod.len() && n1 < num_det { + syn_mod[n1] ^= 1; + } + if n2 < syn_mod.len() && n2 < num_det { + syn_mod[n2] ^= 1; + } + } + + // Re-match with modified weights and syndrome. + let result = self.decoder.decode_with_weights(&syn_mod, &weights); + if let Ok((child_obs, child_edges)) = result { + // The full observable includes committed edges' observables. + let mut full_obs = child_obs; + for &edge_idx in &matched_edges[..j] { + full_obs ^= self.decoder.edge_obs_mask(edge_idx); + } + + // Build child's removed set and flipped set. + let mut child_removed = removed_edges.clone(); + for &re in &matched_edges[commit_idx..=j] { + child_removed.push(re); + } + let mut child_flipped = flipped_detectors.clone(); + for &edge_idx in &matched_edges[commit_idx..j] { + let n1 = self.decoder.edge_node1(edge_idx) as usize; + let n2 = self.decoder.edge_node2(edge_idx) as usize; + if n1 < num_det { + child_flipped.push(n1); + } + if n2 < num_det { + child_flipped.push(n2); + } + } + + predictions.push(full_obs); + + let child_node = TreeNode { + matched_edges: child_edges, + removed_edges: child_removed, + flipped_detectors: child_flipped, + commit_idx: 0, + }; + let child_idx = nodes.len(); + nodes.push(child_node); + // Priority by expansion order (breadth-first). + pq.push((Reverse(child_idx as u64), child_idx)); + } + + if predictions.len() >= k { + break; + } + } + } + + // Majority vote across K predictions. + let half = predictions.len() / 2; + let mut result = 0u64; + for bit in 0..64u32 { + let mask = 1u64 << bit; + let count = predictions.iter().filter(|&&p| p & mask != 0).count(); + if count > half { + result |= mask; + } + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::errors::DecoderError; + + /// Trivial decoder: always returns obs=0, no matched edges. + struct TrivialDecoder { + num_edges: usize, + } + + impl crate::correlated_decoder::MatchingDecoder for TrivialDecoder { + fn decode_with_matching( + &mut self, + _syndrome: &[u8], + ) -> Result<(u64, Vec), DecoderError> { + Ok((0, Vec::new())) + } + fn decode_with_weights( + &mut self, + _syndrome: &[u8], + _weights: &[f64], + ) -> Result<(u64, Vec), DecoderError> { + Ok((0, Vec::new())) + } + fn num_edges(&self) -> usize { + self.num_edges + } + } + + impl crate::correlated_decoder::EdgeTrackingDecoder for TrivialDecoder { + fn edge_node1(&self, _: usize) -> u32 { + 0 + } + fn edge_node2(&self, _: usize) -> u32 { + 1 + } + fn edge_weight(&self, _: usize) -> f64 { + 1.0 + } + fn edge_obs_mask(&self, _: usize) -> u64 { + 0 + } + fn num_detectors(&self) -> usize { + 2 + } + } + + #[test] + fn test_k_mwpm_zero_syndrome() { + let decoder = TrivialDecoder { num_edges: 2 }; + let mut k_dec = KMwpmDecoder::new(decoder, KMwpmConfig { k: 5 }); + let obs = k_dec.decode_to_observables(&[0, 0]).unwrap(); + assert_eq!(obs, 0); + } + + #[test] + fn test_k_mwpm_k1() { + let decoder = TrivialDecoder { num_edges: 2 }; + let mut k_dec = KMwpmDecoder::new(decoder, KMwpmConfig { k: 1 }); + let obs = k_dec.decode_to_observables(&[0, 0]).unwrap(); + assert_eq!(obs, 0); + } +} diff --git a/crates/pecos-decoder-core/src/lib.rs b/crates/pecos-decoder-core/src/lib.rs index d7d47fe1b..6ac5e2bba 100644 --- a/crates/pecos-decoder-core/src/lib.rs +++ b/crates/pecos-decoder-core/src/lib.rs @@ -11,12 +11,44 @@ //! - `matrix` - Common matrix types and check matrix traits //! - `dem` - Detector error model traits and utilities +// Decoder prototypes expose public traits while the API is still stabilizing, +// and their metrics/index conversions intentionally cross integer and floating +// domains. Keep this list narrow: mechanical style lints are fixed in code. +#![allow( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::missing_errors_doc, + clippy::missing_panics_doc, + clippy::needless_pass_by_value +)] + +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; +pub mod correlation_table; +pub mod decode_budget; pub mod dem; +pub mod ensemble; +pub mod erasure; pub mod errors; +pub mod ghost_protocol; +pub mod k_mwpm; +pub mod logical_algorithm; pub mod matrix; +pub mod multi_decoder; +pub mod observable_subgraph; +pub mod pauli_frame; +pub mod perturbed; +pub mod preprocessor; pub mod results; +pub mod streaming; +pub mod telemetry; +pub mod two_pass_decoder; +pub mod windowed_osd; use ndarray::ArrayView1; @@ -28,7 +60,10 @@ pub use advanced::{ pub use config::{ BatchConfig, ConfigBuilder, DecoderConfig, DecodingMethod, PerformanceConfig, SolverType, }; -pub use dem::{DemConfig, DemConfigBuilder, DemDecoder, DemInfo}; +pub use dem::{ + CheckMatrixObservableDecoder, DemCheckMatrix, DemConfig, DemConfigBuilder, DemDecoder, DemInfo, + DemMatchingGraph, DetectorCoord, MatchingEdge, parse_detector_coords, +}; pub use errors::{ConfigError, DecoderError, ErrorConvert, GraphError, MatrixError}; pub use matrix::{CheckMatrixConfig, CheckMatrixDecoder, SparseCheckMatrix}; pub use results::{ @@ -123,6 +158,46 @@ pub trait BatchDecoder: Decoder { -> Result, Self::Error>; } +// ============================================================================ +// Observable Decoder Trait (for sample+decode loops) +// ============================================================================ + +/// Minimal trait for decoders used in threshold estimation loops. +/// +/// Takes a detection event syndrome (dense `&[u8]`), returns the predicted +/// observable flip mask. This is the only interface the sample+decode +/// 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. + /// + /// Bit `i` of the returned value is 1 if observable `i` is predicted to flip. + /// + /// # Errors + /// + /// Returns [`DecoderError`] if decoding fails. + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result; + + /// Batch decode: flat buffer of `num_shots × num_detectors` bytes. + /// Returns one `u64` observable mask per shot. + /// + /// Default: loops over shots calling `decode_to_observables`. + /// Override for decoders with native batch support (e.g. `PyMatching`). + fn decode_batch_to_observables( + &mut self, + shots: &[u8], + num_shots: usize, + num_detectors: usize, + ) -> Result, DecoderError> { + let mut results = Vec::with_capacity(num_shots); + for i in 0..num_shots { + let syn = &shots[i * num_detectors..(i + 1) * num_detectors]; + results.push(self.decode_to_observables(syn)?); + } + Ok(results) + } +} + // ============================================================================ // Re-exports // ============================================================================ diff --git a/crates/pecos-decoder-core/src/logical_algorithm.rs b/crates/pecos-decoder-core/src/logical_algorithm.rs new file mode 100644 index 000000000..cb97a7113 --- /dev/null +++ b/crates/pecos-decoder-core/src/logical_algorithm.rs @@ -0,0 +1,1050 @@ +// 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. + +//! Logical algorithm decoder for real-time QEC. +//! +//! Decodes logical algorithms (sequences of memory segments separated by +//! transversal gates) using the full-circuit DEM for accuracy, with +//! segment structure metadata for streaming and frame propagation. +//! +//! # Decoding Modes +//! +//! - **Full-circuit**: Uses the full DEM's OSD for maximum accuracy. +//! Equivalent to `ObservableSubgraphDecoder` on the full circuit. +//! - **Per-segment** (future streaming): Each segment decoded independently +//! with buffer overlap at gate boundaries. + +use crate::ObservableDecoder; +use crate::errors::DecoderError; + +/// One segment of a logical algorithm. +pub struct SegmentDescriptor { + /// Number of detectors in this segment's DEM. + pub num_detectors: usize, + /// Number of observables in this segment's DEM. + pub num_observables: usize, +} + +/// Gate at a segment boundary. +#[derive(Debug, Clone)] +pub enum BoundaryGate { + /// Transversal Hadamard: swaps X↔Z frame bits for a qubit. + Hadamard { x_obs_bit: u32, z_obs_bit: u32 }, + /// Transversal CNOT: propagates X forward, Z backward. + Cnot { + ctrl_x_bit: u32, + ctrl_z_bit: u32, + tgt_x_bit: u32, + tgt_z_bit: u32, + }, + /// Transversal S gate: X corrections induce Z corrections. + SGate { x_obs_bit: u32, z_obs_bit: u32 }, + /// T-gate via magic state injection (decision point). + /// + /// At this boundary, the decoder MUST produce a correction before + /// the hardware can proceed. The corrected measurement outcome + /// determines whether an S correction is applied: + /// corrected = `raw_measurement` XOR frame[`z_obs_bit`] + /// if corrected == 1: apply S gate on the data qubit + /// + /// This is a feed-forward decision point with a reaction time + /// deadline. The decoder's frame must be ready. + TGateInjection { + /// Observable bit for the data qubit's Z correction. + z_obs_bit: u32, + /// Observable bit for the ancilla's Z measurement. + ancilla_z_bit: u32, + }, +} + +/// Marks whether a segment boundary is a decision point. +/// +/// At decision points, the decoder must provide the Pauli frame +/// within the reaction time budget. At non-decision boundaries +/// (Clifford gates), the frame is metadata — no deadline. +impl BoundaryGate { + /// Whether this gate is a feed-forward decision point. + #[must_use] + pub fn is_decision_point(&self) -> bool { + matches!(self, Self::TGateInjection { .. }) + } +} + +/// Full description of a logical algorithm for decoding. +pub struct AlgorithmDescriptor { + /// Per-segment descriptors. + pub segments: Vec, + /// Gates at segment boundaries. `boundary_gates[i]` between segment i and i+1. + pub boundary_gates: Vec>, + /// Total number of observables. + pub num_observables: usize, +} + +/// Decoder for logical quantum algorithms. +/// +/// Wraps a full-circuit decoder (OSD) 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 +/// metadata for frame tracking and streaming (step 5). +pub struct LogicalAlgorithmDecoder { + /// Full-circuit decoder (OSD on the complete DEM). + full_decoder: Box, + /// Segment metadata for streaming/frame tracking. + segments: Vec, + /// Gates at segment boundaries. + boundary_gates: Vec>, + /// Total number of observables. + _num_observables: usize, +} + +impl LogicalAlgorithmDecoder { + /// Build from a full-circuit decoder and algorithm descriptor. + /// + /// The `full_decoder` is typically an `ObservableSubgraphDecoder` + /// built from the full circuit DEM. + #[must_use] + pub fn new( + full_decoder: Box, + descriptor: AlgorithmDescriptor, + ) -> Self { + Self { + full_decoder, + segments: descriptor.segments, + boundary_gates: descriptor.boundary_gates, + _num_observables: descriptor.num_observables, + } + } + + /// Decode one shot using the full-circuit decoder. + pub fn decode_shot(&mut self, syndrome: &[u8]) -> Result { + self.full_decoder.decode_to_observables(syndrome) + } + + /// Number of segments. + #[must_use] + pub fn num_segments(&self) -> usize { + self.segments.len() + } + + /// Total detectors across all segments. + #[must_use] + pub fn total_detectors(&self) -> usize { + self.segments.iter().map(|s| s.num_detectors).sum() + } + + /// 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) { + match gate { + BoundaryGate::Hadamard { + x_obs_bit, + z_obs_bit, + } => { + let x_set = (*frame >> x_obs_bit) & 1; + let z_set = (*frame >> z_obs_bit) & 1; + *frame &= !(1u64 << x_obs_bit); + *frame &= !(1u64 << z_obs_bit); + *frame |= z_set << x_obs_bit; + *frame |= x_set << z_obs_bit; + } + BoundaryGate::Cnot { + ctrl_x_bit, + ctrl_z_bit, + tgt_x_bit, + tgt_z_bit, + } => { + if (*frame >> ctrl_x_bit) & 1 != 0 { + *frame ^= 1u64 << tgt_x_bit; + } + if (*frame >> tgt_z_bit) & 1 != 0 { + *frame ^= 1u64 << ctrl_z_bit; + } + } + BoundaryGate::SGate { + x_obs_bit, + z_obs_bit, + } => { + if (*frame >> x_obs_bit) & 1 != 0 { + *frame ^= 1u64 << z_obs_bit; + } + } + BoundaryGate::TGateInjection { + z_obs_bit, + ancilla_z_bit, + } => { + // T-gate teleportation: CX(data, ancilla) + measure ancilla Z. + // The ancilla Z measurement outcome (corrected by frame) + // determines whether to apply S correction on data. + // + // Frame propagation: the ancilla's Z observable is folded + // into the data's Z observable. If the ancilla Z bit is + // set in the frame, flip the data's Z bit. + if (*frame >> ancilla_z_bit) & 1 != 0 { + *frame ^= 1u64 << z_obs_bit; + } + } + } + } +} + +impl ObservableDecoder for LogicalAlgorithmDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + self.decode_shot(syndrome) + } +} + +// ============================================================================ +// Streaming mode +// ============================================================================ + +/// Streaming wrapper for `LogicalAlgorithmDecoder`. +/// +/// Buffers syndrome data round-by-round. The full-circuit OSD decodes +/// the entire accumulated syndrome at `flush()` for maximum accuracy. +/// +/// The segment structure tracks which rounds belong to which segment. +/// At each segment boundary, the Pauli frame can be queried and +/// propagated through the boundary gate. +/// +/// # Usage +/// +/// ``` +/// use pecos_decoder_core::{DecoderError, ObservableDecoder}; +/// use pecos_decoder_core::logical_algorithm::{ +/// AlgorithmDescriptor, LogicalAlgorithmDecoder, SegmentDescriptor, StreamingLogicalDecoder, +/// }; +/// +/// struct AnyDetectionDecoder; +/// +/// impl ObservableDecoder for AnyDetectionDecoder { +/// fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { +/// Ok(u64::from(syndrome.iter().any(|&bit| bit != 0))) +/// } +/// } +/// +/// let descriptor = AlgorithmDescriptor { +/// segments: vec![SegmentDescriptor { +/// num_detectors: 2, +/// num_observables: 1, +/// }], +/// boundary_gates: vec![], +/// num_observables: 1, +/// }; +/// let decoder = LogicalAlgorithmDecoder::new(Box::new(AnyDetectionDecoder), descriptor); +/// let mut stream = StreamingLogicalDecoder::new(decoder); +/// +/// // Feed syndrome round by round +/// for sparse_round in [vec![(0, 1)], vec![(1, 0)]] { +/// stream.feed_sparse(&sparse_round); +/// } +/// +/// // Decode at the end +/// let obs = stream.flush().unwrap(); +/// assert_eq!(obs, 1); +/// ``` +pub struct StreamingLogicalDecoder { + /// The underlying batch decoder (full-circuit OSD). + inner: LogicalAlgorithmDecoder, + /// Accumulated syndrome buffer (full circuit size). + syndrome: Vec, + /// Total detectors. + total_detectors: usize, + /// Rounds fed so far. + rounds_fed: usize, + /// Accumulated observable correction from last flush. + accumulated_obs: u64, +} + +impl StreamingLogicalDecoder { + /// Create from a `LogicalAlgorithmDecoder`. + #[must_use] + pub fn new(decoder: LogicalAlgorithmDecoder) -> Self { + let total = decoder.total_detectors(); + Self { + inner: decoder, + syndrome: vec![0u8; total], + total_detectors: total, + rounds_fed: 0, + accumulated_obs: 0, + } + } + + /// Feed one detection event into the syndrome buffer. + #[inline] + pub fn feed_detection(&mut self, detector_idx: usize, value: u8) { + if detector_idx < self.total_detectors { + self.syndrome[detector_idx] = value; + } + } + + /// Feed a dense syndrome slice (all detectors, in order). + pub fn feed_dense(&mut self, syndrome: &[u8]) { + let len = syndrome.len().min(self.total_detectors); + self.syndrome[..len].copy_from_slice(&syndrome[..len]); + } + + /// Feed sparse detection events: (`detector_index`, value) pairs. + pub fn feed_sparse(&mut self, detectors: &[(u32, u8)]) { + for &(det, val) in detectors { + self.feed_detection(det as usize, val); + } + self.rounds_fed += 1; + } + + /// Decode the accumulated syndrome using the full-circuit OSD. + /// + /// Returns the observable correction mask. This is the final + /// correction to apply to raw measurement outcomes. + pub fn flush(&mut self) -> Result { + let obs = self.inner.decode_shot(&self.syndrome)?; + self.accumulated_obs = obs; + Ok(obs) + } + + /// Decode a full syndrome at once (convenience for batch mode). + pub fn decode_shot(&mut self, syndrome: &[u8]) -> Result { + self.feed_dense(syndrome); + self.flush() + } + + /// Current accumulated observable correction. + #[must_use] + pub fn accumulated_obs(&self) -> u64 { + self.accumulated_obs + } + + /// Number of segments in the algorithm. + #[must_use] + pub fn num_segments(&self) -> usize { + self.inner.num_segments() + } + + /// Rounds fed so far. + #[must_use] + pub fn rounds_fed(&self) -> usize { + self.rounds_fed + } + + /// Access the boundary gates for frame propagation. + #[must_use] + pub fn boundary_gates(&self) -> &[Vec] { + &self.inner.boundary_gates + } + + /// 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); + } + + /// Reset for the next shot. + pub fn reset(&mut self) { + self.syndrome.fill(0); + self.rounds_fed = 0; + self.accumulated_obs = 0; + } +} + +/// Simulate streaming decode on a batch of samples. +/// +/// For each shot: feeds the dense syndrome, flushes, checks against expected. +/// Returns the number of logical errors. This simulates what a real-time +/// system would do — feed syndromes and flush at the end. +pub fn streaming_decode_count( + decoder: &mut StreamingLogicalDecoder, + syndromes: &[Vec], + expected_masks: &[u64], +) -> Result { + let mut errors = 0; + for (syn, &expected) in syndromes.iter().zip(expected_masks.iter()) { + decoder.reset(); + let predicted = decoder.decode_shot(syn)?; + if predicted != expected { + errors += 1; + } + } + Ok(errors) +} + +// ============================================================================ +// Budget-aware logical circuit decoder +// ============================================================================ + +use crate::decode_budget::{DecodeBudget, DecodeStrategy, DetectorRegion}; + +/// Budget-aware decoder for logical quantum circuits. +/// +/// Composes a `DecodeStrategy` (which handles the decode/commit pattern) +/// with segment tracking and Pauli frame propagation. The strategy is +/// selected based on the hardware's time budget. +/// +/// # Decode Modes +/// +/// - **Offline** (ion trap / simulation): `FullCircuitStrategy` — buffer +/// everything, decode at end. Maximum accuracy. +/// - **Streaming** (neutral atom): `CommittedOsdStrategy` — decode and +/// commit at segment boundaries. Bounded memory. +/// - **Real-time** (superconducting): windowed UF with ghost protocol +/// (future). +/// +/// All modes use the same segment + gate + frame infrastructure. +pub struct LogicalCircuitDecoder { + /// The decode strategy (owns the inner decoder). + strategy: Box, + /// Segment metadata. + segments: Vec, + /// Cumulative detector offsets per segment. + _segment_offsets: Vec, + /// Gates at segment boundaries. + boundary_gates: Vec>, + /// Per-qubit Pauli frames. + frames: Vec, + /// Decode budget. + budget: DecodeBudget, + /// Syndrome buffer. + syndrome: Vec, + /// Total detectors. + total_detectors: usize, + /// Current segment being fed. + current_segment: usize, + /// Detectors fed into the current segment so far. + current_segment_fed: usize, +} + +impl LogicalCircuitDecoder { + /// Build from an algorithm descriptor, decode strategy, and budget. + #[must_use] + pub fn new( + descriptor: AlgorithmDescriptor, + strategy: Box, + budget: DecodeBudget, + num_qubits: usize, + ) -> Self { + let mut segment_offsets = Vec::with_capacity(descriptor.segments.len()); + let mut offset = 0; + for seg in &descriptor.segments { + segment_offsets.push(offset); + offset += seg.num_detectors; + } + let total_detectors = offset; + + Self { + strategy, + segments: descriptor.segments, + _segment_offsets: segment_offsets, + boundary_gates: descriptor.boundary_gates, + frames: vec![0u64; num_qubits], + budget, + syndrome: vec![0u8; total_detectors], + total_detectors, + current_segment: 0, + current_segment_fed: 0, + } + } + + /// Decode a full shot (batch mode). + /// + /// For offline/ion trap budgets: equivalent to full-circuit OSD. + /// For streaming budgets: decodes and commits each segment. + pub fn decode_shot(&mut self, full_syndrome: &[u8]) -> Result { + self.reset(); + let len = full_syndrome.len().min(self.total_detectors); + self.syndrome[..len].copy_from_slice(&full_syndrome[..len]); + + // Single decode of the full syndrome. The strategy handles + // commitment internally if it supports it. + self.strategy.decode(&self.syndrome) + } + + /// Batch decode: count logical errors across a batch of shots. + pub fn decode_count( + &mut self, + syndromes: &[Vec], + expected_masks: &[u64], + ) -> Result { + let mut errors = 0; + for (syn, &expected) in syndromes.iter().zip(expected_masks.iter()) { + let predicted = self.decode_shot(syn)?; + if predicted != expected { + errors += 1; + } + } + Ok(errors) + } + + /// Number of segments. + #[must_use] + pub fn num_segments(&self) -> usize { + self.segments.len() + } + + /// Whether the algorithm has any feed-forward decision points. + /// + /// If false, the budget doesn't matter — all corrections are + /// metadata applied at the end (Clifford-only circuit). + /// If true, the reaction time budget is meaningful. + #[must_use] + pub fn has_decision_points(&self) -> bool { + self.boundary_gates + .iter() + .any(|gates| gates.iter().any(BoundaryGate::is_decision_point)) + } + + /// Number of decision points (T gates, magic state injections). + #[must_use] + pub fn num_decision_points(&self) -> usize { + self.boundary_gates + .iter() + .flat_map(|gates| gates.iter()) + .filter(|g| g.is_decision_point()) + .count() + } + + /// Total detectors. + #[must_use] + pub fn total_detectors(&self) -> usize { + self.total_detectors + } + + /// Current Pauli frames (per qubit). + #[must_use] + pub fn frames(&self) -> &[u64] { + &self.frames + } + + /// The decode budget. + #[must_use] + pub fn budget(&self) -> &DecodeBudget { + &self.budget + } + + /// Reset for next shot. + pub fn reset(&mut self) { + self.strategy.reset(); + self.syndrome.fill(0); + self.frames.fill(0); + self.current_segment = 0; + self.current_segment_fed = 0; + } +} + +impl ObservableDecoder for LogicalCircuitDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + self.decode_shot(syndrome) + } +} + +// ============================================================================ +// Strategy: Full Circuit (offline / ion trap) +// ============================================================================ + +/// Full-circuit decode strategy. +/// +/// Buffers the entire syndrome, decodes at flush. Maximum accuracy. +/// Used for offline analysis, ion trap systems, or any budget that +/// allows full-circuit processing. +pub struct FullCircuitStrategy { + inner: Box, +} + +impl FullCircuitStrategy { + /// Wrap any `ObservableDecoder` (typically OSD). + #[must_use] + pub fn new(decoder: Box) -> Self { + Self { inner: decoder } + } +} + +impl DecodeStrategy for FullCircuitStrategy { + fn decode(&mut self, syndrome: &[u8]) -> Result { + self.inner.decode_to_observables(syndrome) + } + + fn commit(&mut self, _region: &DetectorRegion) -> Result { + // Full circuit doesn't commit incrementally + Ok(0) + } + + fn committed_obs(&self) -> u64 { + 0 + } + + fn reset(&mut self) { + // No state to reset for full-circuit strategy + } +} + +// ============================================================================ +// Strategy: Windowed OSD (neutral atom / medium budget) +// ============================================================================ + +/// Windowed OSD strategy: per-observable 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 { + /// Per-subgraph decoders (windowed or plain). + subgraph_decoders: Vec>, + /// Per-subgraph detector maps: `subgraph_detector_maps`[i][local] = global. + detector_maps: Vec>, + /// Per-subgraph sub-syndrome buffers (reusable). + sub_syndromes: Vec>, + /// Number of observables. + _num_observables: usize, +} + +impl WindowedOsdStrategy { + /// Build from pre-extracted subgraph DEMs and detector maps. + /// + /// `subgraph_dems`: per-observable DEM strings (graphlike). + /// `detector_maps`: per-observable local→global detector index maps. + /// `factory`: creates the inner decoder for each subgraph DEM. + pub fn new( + subgraph_dems: Vec, + detector_maps: 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); + + 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); + sub_syndromes.push(vec![0u8; n]); + decoders.push(dec); + } + + Ok(Self { + subgraph_decoders: decoders, + detector_maps, + sub_syndromes, + _num_observables: num_observables, + }) + } +} + +impl DecodeStrategy for WindowedOsdStrategy { + fn decode(&mut self, syndrome: &[u8]) -> Result { + let mut obs_mask = 0u64; + + for (i, (dec, dmap)) in self + .subgraph_decoders + .iter_mut() + .zip(self.detector_maps.iter()) + .enumerate() + { + let n = dmap.len(); + if n == 0 { + continue; + } + + // Route global syndrome to subgraph-local syndrome + let buf = &mut self.sub_syndromes[i]; + for (local, &global) in dmap.iter().enumerate() { + buf[local] = if global < syndrome.len() { + syndrome[global] + } else { + 0 + }; + } + + // Decode this subgraph + let sub_obs = dec.decode_to_observables(&buf[..n])?; + if sub_obs & 1 != 0 { + obs_mask |= 1 << i; + } + } + + Ok(obs_mask) + } + + fn commit(&mut self, _region: &DetectorRegion) -> Result { + // Commitment is handled internally by the windowed inner decoders + Ok(0) + } + + fn committed_obs(&self) -> u64 { + 0 + } + + fn reset(&mut self) { + for buf in &mut self.sub_syndromes { + buf.fill(0); + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FixedDecoder(u64); + impl ObservableDecoder for FixedDecoder { + fn decode_to_observables(&mut self, _: &[u8]) -> Result { + Ok(self.0) + } + } + + #[test] + fn test_single_segment() { + let desc = AlgorithmDescriptor { + segments: vec![SegmentDescriptor { + num_detectors: 4, + num_observables: 2, + }], + boundary_gates: vec![], + num_observables: 2, + }; + let mut dec = LogicalAlgorithmDecoder::new(Box::new(FixedDecoder(0b01)), desc); + assert_eq!(dec.decode_shot(&[0, 1, 0, 1]).unwrap(), 0b01); + } + + #[test] + fn test_hadamard_frame() { + let mut frame = 0b01u64; // X correction on bit 0 + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::Hadamard { + x_obs_bit: 0, + z_obs_bit: 1, + }, + ); + assert_eq!(frame, 0b10); // X became Z + } + + #[test] + fn test_cnot_frame() { + let mut frame = 0b0001u64; // X on control (bit 0) + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::Cnot { + ctrl_x_bit: 0, + ctrl_z_bit: 1, + tgt_x_bit: 2, + tgt_z_bit: 3, + }, + ); + assert_eq!(frame, 0b0101); // X propagated to target + } + + #[test] + fn test_logical_circuit_decoder_unlimited() { + let desc = AlgorithmDescriptor { + segments: vec![ + SegmentDescriptor { + num_detectors: 4, + num_observables: 2, + }, + SegmentDescriptor { + num_detectors: 4, + num_observables: 2, + }, + ], + boundary_gates: vec![vec![BoundaryGate::Hadamard { + x_obs_bit: 0, + z_obs_bit: 1, + }]], + num_observables: 2, + }; + + let strategy = FullCircuitStrategy::new(Box::new(FixedDecoder(0b01))); + let budget = DecodeBudget::unlimited(); + + let mut dec = LogicalCircuitDecoder::new(desc, Box::new(strategy), budget, 1); + let result = dec.decode_shot(&[0, 0, 0, 0, 0, 0, 0, 0]).unwrap(); + assert_eq!(result, 0b01); + } + + #[test] + fn test_cnot_frame_z_backward() { + // Z on target should propagate back to control + let mut frame = 0b1000u64; // Z on target (bit 3) + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::Cnot { + ctrl_x_bit: 0, + ctrl_z_bit: 1, + tgt_x_bit: 2, + tgt_z_bit: 3, + }, + ); + assert_eq!(frame, 0b1010); // Z propagated back to control Z (bit 1) + } + + #[test] + fn test_cnot_frame_both_directions() { + // X on control + Z on target -> both propagate + let mut frame = 0b1001u64; // X on ctrl (bit 0), Z on tgt (bit 3) + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::Cnot { + ctrl_x_bit: 0, + ctrl_z_bit: 1, + tgt_x_bit: 2, + tgt_z_bit: 3, + }, + ); + // X ctrl -> X tgt (bit 2), Z tgt -> Z ctrl (bit 1) + assert_eq!(frame, 0b1111); + } + + #[test] + fn test_sgate_frame_x_induces_z() { + // S gate: X correction induces Z correction (X -> XZ = Y) + let mut frame = 0b01u64; // X correction on bit 0 + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::SGate { + x_obs_bit: 0, + z_obs_bit: 1, + }, + ); + assert_eq!(frame, 0b11); // X stays, Z also set + } + + #[test] + fn test_sgate_frame_z_unchanged() { + // S gate: Z correction is unchanged (S commutes with Z) + let mut frame = 0b10u64; // Z correction on bit 1 + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::SGate { + x_obs_bit: 0, + z_obs_bit: 1, + }, + ); + assert_eq!(frame, 0b10); // Z stays, no X induced + } + + #[test] + fn test_sgate_frame_no_correction() { + let mut frame = 0u64; + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::SGate { + x_obs_bit: 0, + z_obs_bit: 1, + }, + ); + assert_eq!(frame, 0); // No correction, no change + } + + #[test] + fn test_t_injection_frame_ancilla_z_folds() { + // T injection: ancilla Z bit folds into data Z bit + let mut frame = 0b1000u64; // ancilla Z set (bit 3) + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::TGateInjection { + z_obs_bit: 1, // data Z + ancilla_z_bit: 3, // ancilla Z + }, + ); + assert_eq!(frame, 0b1010); // data Z (bit 1) flipped + } + + #[test] + fn test_t_injection_frame_ancilla_z_cancels() { + // If data Z already set and ancilla Z set, they cancel (XOR) + let mut frame = 0b1010u64; // both data Z (bit 1) and ancilla Z (bit 3) + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::TGateInjection { + z_obs_bit: 1, + ancilla_z_bit: 3, + }, + ); + assert_eq!(frame, 0b1000); // data Z cancelled, ancilla unchanged + } + + #[test] + fn test_t_injection_frame_no_ancilla_z() { + // No ancilla Z -> no change + let mut frame = 0b0010u64; // data Z set, ancilla Z not set + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::TGateInjection { + z_obs_bit: 1, + ancilla_z_bit: 3, + }, + ); + assert_eq!(frame, 0b0010); // unchanged + } + + #[test] + fn test_hadamard_frame_swap_both() { + // Both X and Z set -> swap + let mut frame = 0b11u64; + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::Hadamard { + x_obs_bit: 0, + z_obs_bit: 1, + }, + ); + assert_eq!(frame, 0b11); // Swap of (1,1) is still (1,1) + } + + #[test] + fn test_hadamard_frame_z_to_x() { + let mut frame = 0b10u64; // Z only + LogicalAlgorithmDecoder::apply_boundary_gate( + &mut frame, + &BoundaryGate::Hadamard { + x_obs_bit: 0, + z_obs_bit: 1, + }, + ); + assert_eq!(frame, 0b01); // Z became X + } + + #[test] + fn test_is_decision_point() { + assert!( + BoundaryGate::TGateInjection { + z_obs_bit: 1, + ancilla_z_bit: 3, + } + .is_decision_point() + ); + + assert!( + !BoundaryGate::Hadamard { + x_obs_bit: 0, + z_obs_bit: 1, + } + .is_decision_point() + ); + + assert!( + !BoundaryGate::Cnot { + ctrl_x_bit: 0, + ctrl_z_bit: 1, + tgt_x_bit: 2, + tgt_z_bit: 3, + } + .is_decision_point() + ); + + assert!( + !BoundaryGate::SGate { + x_obs_bit: 0, + z_obs_bit: 1, + } + .is_decision_point() + ); + } + + #[test] + fn test_budget_windowed_vs_unlimited() { + use std::time::Duration; + let windowed = DecodeBudget::from_reaction_time(Duration::from_millis(1), 7); + assert!(windowed.is_windowed()); + + let unlimited = DecodeBudget::unlimited(); + assert!(unlimited.is_unlimited()); + } + + #[test] + fn test_streaming_feed_dense_and_flush() { + let desc = AlgorithmDescriptor { + segments: vec![SegmentDescriptor { + num_detectors: 4, + num_observables: 2, + }], + boundary_gates: vec![], + num_observables: 2, + }; + let inner = LogicalAlgorithmDecoder::new(Box::new(FixedDecoder(0b10)), desc); + let mut streaming = StreamingLogicalDecoder::new(inner); + + // Feed full syndrome at once + let result = streaming.decode_shot(&[0, 1, 0, 1]).unwrap(); + assert_eq!(result, 0b10); + assert_eq!(streaming.accumulated_obs(), 0b10); + } + + #[test] + fn test_streaming_feed_sparse() { + let desc = AlgorithmDescriptor { + segments: vec![SegmentDescriptor { + num_detectors: 4, + num_observables: 2, + }], + boundary_gates: vec![], + num_observables: 2, + }; + let inner = LogicalAlgorithmDecoder::new(Box::new(FixedDecoder(0b01)), desc); + let mut streaming = StreamingLogicalDecoder::new(inner); + + // Feed individual detectors + streaming.feed_detection(1, 1); + streaming.feed_detection(3, 1); + let result = streaming.flush().unwrap(); + assert_eq!(result, 0b01); + } + + #[test] + fn test_streaming_reset() { + let desc = AlgorithmDescriptor { + segments: vec![SegmentDescriptor { + num_detectors: 4, + num_observables: 2, + }], + boundary_gates: vec![], + num_observables: 2, + }; + let inner = LogicalAlgorithmDecoder::new(Box::new(FixedDecoder(0b11)), desc); + let mut streaming = StreamingLogicalDecoder::new(inner); + + streaming.decode_shot(&[1, 0, 1, 0]).unwrap(); + assert_eq!(streaming.accumulated_obs(), 0b11); + + streaming.reset(); + assert_eq!(streaming.accumulated_obs(), 0); + } + + #[test] + fn test_streaming_decode_count() { + let desc = AlgorithmDescriptor { + segments: vec![SegmentDescriptor { + num_detectors: 2, + num_observables: 1, + }], + boundary_gates: vec![], + num_observables: 1, + }; + let inner = LogicalAlgorithmDecoder::new( + Box::new(FixedDecoder(0b1)), + desc, // always predicts obs flip + ); + let mut streaming = StreamingLogicalDecoder::new(inner); + + let syndromes = vec![vec![0u8, 0], vec![1, 0], vec![0, 1]]; + let expected = vec![0b1, 0b0, 0b1]; // matches on shot 0 and 2 + + let errors = streaming_decode_count(&mut streaming, &syndromes, &expected).unwrap(); + assert_eq!(errors, 1); // only shot 1 is wrong (predicted 1, expected 0) + } +} diff --git a/crates/pecos-decoder-core/src/multi_decoder.rs b/crates/pecos-decoder-core/src/multi_decoder.rs new file mode 100644 index 000000000..c88e291b6 --- /dev/null +++ b/crates/pecos-decoder-core/src/multi_decoder.rs @@ -0,0 +1,245 @@ +// 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. + +//! Multi-logical-qubit decoder manager. +//! +//! Manages decoder instances for K logical qubits, each with its own +//! Pauli frame. Routes syndromes to the right decoder and maintains +//! per-qubit frames. + +use crate::ObservableDecoder; +use crate::errors::DecoderError; + +/// Manages multiple logical qubit decoders with per-qubit Pauli frames. +pub struct MultiDecoderManager { + /// (label, decoder) per logical qubit. + decoders: Vec<(String, Box)>, + /// Per-qubit accumulated Pauli frame. + frames: Vec, + /// Per-qubit cycle count. + cycle_counts: Vec, +} + +impl MultiDecoderManager { + /// Create with no decoders. + #[must_use] + pub fn new() -> Self { + Self { + decoders: Vec::new(), + frames: Vec::new(), + cycle_counts: Vec::new(), + } + } + + /// Add a logical qubit decoder with a label. + pub fn add_qubit(&mut self, label: impl Into, decoder: Box) { + self.decoders.push((label.into(), decoder)); + self.frames.push(0); + self.cycle_counts.push(0); + } + + /// Number of logical qubits managed. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.decoders.len() + } + + /// Decode one QEC cycle for a specific logical qubit. + /// + /// # Errors + /// + /// Returns `DecoderError` if the decoder fails or the qubit index is out of bounds. + pub fn decode_cycle(&mut self, qubit_idx: usize, syndrome: &[u8]) -> Result { + if qubit_idx >= self.decoders.len() { + return Err(DecoderError::InvalidNodeIndex { + index: qubit_idx, + max: self.decoders.len(), + }); + } + let obs = self.decoders[qubit_idx].1.decode_to_observables(syndrome)?; + self.frames[qubit_idx] ^= obs; + self.cycle_counts[qubit_idx] += 1; + Ok(obs) + } + + /// Get the current Pauli frame for a logical qubit. + #[must_use] + pub fn frame(&self, qubit_idx: usize) -> u64 { + self.frames.get(qubit_idx).copied().unwrap_or(0) + } + + /// Consume and reset the frame for a logical qubit. + pub fn consume_frame(&mut self, qubit_idx: usize) -> u64 { + if qubit_idx >= self.frames.len() { + return 0; + } + let f = self.frames[qubit_idx]; + self.frames[qubit_idx] = 0; + self.cycle_counts[qubit_idx] = 0; + f + } + + /// Label of a logical qubit. + #[must_use] + pub fn label(&self, qubit_idx: usize) -> Option<&str> { + self.decoders.get(qubit_idx).map(|(l, _)| l.as_str()) + } + + /// Apply a transversal CNOT between two logical qubits' Pauli frames. + /// + /// `x_obs_mask`: observable bits that are X-type (propagate control→target). + /// `z_obs_mask`: observable bits that are Z-type (propagate target→control). + /// + /// For a standard surface code with observable 0 = logical observable: + /// use `x_obs_mask = 1, z_obs_mask = 1` (single observable, both X and Z + /// corrections matter depending on the basis). + pub fn apply_transversal_cnot( + &mut self, + control_idx: usize, + target_idx: usize, + x_obs_mask: u64, + z_obs_mask: u64, + ) { + if control_idx >= self.frames.len() || target_idx >= self.frames.len() { + return; + } + let ctrl_frame = self.frames[control_idx]; + let tgt_frame = self.frames[target_idx]; + + // X-type: control → target + self.frames[target_idx] ^= ctrl_frame & x_obs_mask; + // Z-type: target → control + self.frames[control_idx] ^= tgt_frame & z_obs_mask; + } + + /// Apply a logical Hadamard to a qubit's Pauli frame. + /// + /// Swaps X-type and Z-type frame bits. + pub fn apply_hadamard(&mut self, qubit_idx: usize, x_obs_mask: u64, z_obs_mask: u64) { + if qubit_idx >= self.frames.len() { + return; + } + let f = self.frames[qubit_idx]; + let x_bits = f & x_obs_mask; + let z_bits = f & z_obs_mask; + self.frames[qubit_idx] &= !(x_obs_mask | z_obs_mask); + self.frames[qubit_idx] |= if x_bits != 0 { z_obs_mask } else { 0 }; + self.frames[qubit_idx] |= if z_bits != 0 { x_obs_mask } else { 0 }; + } + + /// Mutable access to the frame for a qubit (for custom gate propagation). + pub fn frame_mut(&mut self, qubit_idx: usize) -> Option<&mut u64> { + self.frames.get_mut(qubit_idx) + } + + /// Replace the decoder for a qubit (e.g., after lattice surgery changes the DEM). + /// + /// # Errors + /// + /// Returns error if `qubit_idx` is out of bounds. + pub fn replace_decoder( + &mut self, + qubit_idx: usize, + decoder: Box, + ) -> Result<(), crate::errors::DecoderError> { + if qubit_idx >= self.decoders.len() { + return Err(crate::errors::DecoderError::InvalidNodeIndex { + index: qubit_idx, + max: self.decoders.len(), + }); + } + self.decoders[qubit_idx].1 = decoder; + Ok(()) + } +} + +impl Default for MultiDecoderManager { + fn default() -> Self { + Self::new() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FixedDecoder(u64); + impl ObservableDecoder for FixedDecoder { + fn decode_to_observables(&mut self, _: &[u8]) -> Result { + Ok(self.0) + } + } + + #[test] + fn test_multi_qubit() { + let mut mgr = MultiDecoderManager::new(); + mgr.add_qubit("q0", Box::new(FixedDecoder(0b01))); + mgr.add_qubit("q1", Box::new(FixedDecoder(0b10))); + + assert_eq!(mgr.num_qubits(), 2); + assert_eq!(mgr.label(0), Some("q0")); + + mgr.decode_cycle(0, &[]).unwrap(); + mgr.decode_cycle(1, &[]).unwrap(); + + assert_eq!(mgr.frame(0), 0b01); + assert_eq!(mgr.frame(1), 0b10); + } + + #[test] + fn test_consume_frame() { + let mut mgr = MultiDecoderManager::new(); + mgr.add_qubit("q0", Box::new(FixedDecoder(1))); + mgr.decode_cycle(0, &[]).unwrap(); + assert_eq!(mgr.consume_frame(0), 1); + assert_eq!(mgr.frame(0), 0); + } + + #[test] + fn test_transversal_cnot() { + let mut mgr = MultiDecoderManager::new(); + mgr.add_qubit("ctrl", Box::new(FixedDecoder(0))); + mgr.add_qubit("tgt", Box::new(FixedDecoder(0))); + + // Set X correction on control (bit 0). + *mgr.frame_mut(0).unwrap() = 0b01; + + // Transversal CNOT: X propagates ctrl→tgt. + mgr.apply_transversal_cnot(0, 1, 0b01, 0b10); + assert_eq!(mgr.frame(0), 0b01); + assert_eq!(mgr.frame(1), 0b01); // X propagated + } + + #[test] + fn test_hadamard() { + let mut mgr = MultiDecoderManager::new(); + mgr.add_qubit("q0", Box::new(FixedDecoder(0))); + *mgr.frame_mut(0).unwrap() = 0b01; // X correction + + mgr.apply_hadamard(0, 0b01, 0b10); + assert_eq!(mgr.frame(0), 0b10); // became Z correction + } + + #[test] + fn test_replace_decoder() { + let mut mgr = MultiDecoderManager::new(); + mgr.add_qubit("q0", Box::new(FixedDecoder(0b01))); + mgr.decode_cycle(0, &[]).unwrap(); + assert_eq!(mgr.frame(0), 0b01); + + // Replace decoder (e.g., after lattice surgery changes the DEM). + mgr.replace_decoder(0, Box::new(FixedDecoder(0b10))) + .unwrap(); + mgr.decode_cycle(0, &[]).unwrap(); + assert_eq!(mgr.frame(0), 0b11); // 01 ^ 10 = 11 + } +} diff --git a/crates/pecos-decoder-core/src/observable_subgraph.rs b/crates/pecos-decoder-core/src/observable_subgraph.rs new file mode 100644 index 000000000..31ce1480a --- /dev/null +++ b/crates/pecos-decoder-core/src/observable_subgraph.rs @@ -0,0 +1,949 @@ +// 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-observable 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. +//! +//! # Algorithm +//! +//! 1. Classify each detector by (`logical_qubit`, `stabilizer_type`) using +//! spatial coordinates +//! 2. For each observable, find its boundary edges (1-detector mechanisms) +//! 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 +//! +//! # Observing Region +//! +//! The observing region for observable k is NOT a transitive closure over +//! shared detectors. It is determined by the *physical structure*: +//! - Find boundary edges (1-detector + observable) for observable k +//! - Each boundary edge's detector belongs to a (qubit, `stab_type`) group +//! - ALL detectors in those groups form the observing region +//! - This preserves the graphlike property of each subgraph + +use std::collections::{BTreeMap, BTreeSet}; + +use crate::ObservableDecoder; +use crate::dem::{DemMatchingGraph, MatchingEdge}; +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); + } + if d > max_detector { + max_detector = d; + has_any_detector = true; + } + } + } 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); + if d > max_detector { + max_detector = d; + has_any_detector = true; + } + } + } 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); + if d > max_detector { + max_detector = d; + has_any_detector = true; + } + } + } + } + // 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 +// ============================================================================ + +/// Identifies a group of detectors by logical qubit and stabilizer type. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DetectorGroup { + pub qubit_idx: usize, + pub stab_type: StabType, +} + +/// Stabilizer type (X or Z). +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub enum StabType { + X, + Z, +} + +/// Stabilizer coordinate map for one logical qubit. +/// +/// Maps stabilizer spatial positions to their type (X or Z). +/// Used to classify detectors by their coordinates. +#[derive(Debug, Clone)] +pub struct QubitStabCoords { + /// X-stabilizer ancilla positions. + pub x_positions: Vec<(f64, f64)>, + /// Z-stabilizer ancilla positions. + pub z_positions: Vec<(f64, f64)>, +} + +/// Stabilizer coordinates for all logical qubits. +/// +/// Entry `i` describes the stabilizers of logical qubit `i`. +pub type StabCoords = Vec; + +/// Classify a detector's spatial coordinates into a `DetectorGroup`. +/// +/// Finds the nearest stabilizer position across all qubits and returns +/// the matching (`qubit_idx`, `stab_type`). Uses exact floating-point +/// comparison with a small tolerance for rounding. +#[must_use] +pub fn classify_detector(x: f64, y: f64, stab_coords: &StabCoords) -> Option { + let eps = 0.01; + for (qubit_idx, qsc) in stab_coords.iter().enumerate() { + for &(sx, sy) in &qsc.x_positions { + if (x - sx).abs() < eps && (y - sy).abs() < eps { + return Some(DetectorGroup { + qubit_idx, + stab_type: StabType::X, + }); + } + } + for &(sx, sy) in &qsc.z_positions { + if (x - sx).abs() < eps && (y - sy).abs() < eps { + return Some(DetectorGroup { + qubit_idx, + stab_type: StabType::Z, + }); + } + } + } + None +} + +// ============================================================================ +// Subgraph partitioning +// ============================================================================ + +/// A sub-DEM for one observable's observing region. +#[derive(Debug, Clone)] +pub struct ObservableSubgraph { + /// Which observable this subgraph decodes. + pub observable_idx: usize, + /// Maps subgraph detector index → full DEM detector index. + pub detector_map: Vec, + /// Maps full DEM detector index → subgraph detector index (None if outside). + pub inverse_map: Vec>, + /// The matching graph for this subgraph. + pub graph: DemMatchingGraph, +} + +/// Partition a DEM into per-observable subgraphs using stabilizer coordinates. +/// +/// This is the correct algorithm: uses the physical structure (which detectors +/// belong to which stabilizer type on which qubit) to determine observing +/// regions, rather than a topological transitive closure. +/// +/// # Arguments +/// +/// * `dem_str` — DEM string in Stim format. Must include `detector(...) D_i` +/// declarations with spatial coordinates. +/// * `stab_coords` — Per-qubit stabilizer coordinate map. Entry `i` gives +/// the X and Z ancilla positions for logical qubit `i`. +/// +/// # Errors +/// +/// Returns error if the DEM is malformed or detector coordinates don't +/// match any stabilizer position. +/// +/// Extra time padding around each boundary edge. +/// `None` = exact boundary edge times only (default, matches lomatching). +/// `Some(r)` = include detectors at times `t ± r` around each boundary +/// edge time `t`, for additional matching context. +pub type MaxTimeRadius = Option; + +pub fn partition_dem_by_observable( + dem_str: &str, + stab_coords: &StabCoords, +) -> Result, DecoderError> { + partition_dem_by_observable_windowed(dem_str, stab_coords, None) +} + +pub fn partition_dem_by_observable_windowed( + dem_str: &str, + stab_coords: &StabCoords, + max_time_radius: MaxTimeRadius, +) -> Result, DecoderError> { + // Single-pass sparse DEM parsing: mechanisms + detector coordinates. + let sdem = SparseDem::from_dem_str(dem_str)?; + let coord_map = &sdem.detector_coords; + + // 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(); + + 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); + } + } + } + + // For each observable, find its observing region. + let mut subgraphs = 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. + let mut group_times: BTreeMap> = BTreeMap::new(); + + for (_, dets, obs) in &sdem.mechanisms { + if !obs.contains(&(obs_idx as u32)) { + continue; + } + if dets.len() == 1 { + let d = dets[0] as usize; + if let Some(group) = det_group[d] { + let time = coord_map + .get(&d) + .and_then(|c| c.last().copied()) + .map_or(0, |t| t as i64); + group_times.entry(group).or_default().insert(time); + } + } + } + + // 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) { + for &d in dets { + let det_time = coord_map + .get(&d) + .and_then(|c| c.last().copied()) + .map_or(0, |t| t as i64); + let in_region = if let Some(radius) = max_time_radius { + times.iter().any(|&t| (det_time - t).abs() <= radius) + } else { + times.contains(&det_time) + }; + if in_region { + region_detectors.insert(d); + } + } + } + } + + if region_detectors.is_empty() { + subgraphs.push(ObservableSubgraph { + observable_idx: obs_idx, + detector_map: Vec::new(), + inverse_map: vec![None; sdem.num_detectors], + graph: DemMatchingGraph { + edges: Vec::new(), + num_detectors: 0, + num_observables: 1, + skipped_hyperedges: 0, + detector_coords: Vec::new(), + }, + }); + continue; + } + + // Step 3: Build detector mapping. + let detector_map: Vec = region_detectors.into_iter().collect(); + 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. + let mut edges = Vec::new(); + let mut skipped = 0; + + for (m, (p, dets, obs)) in sdem.mechanisms.iter().enumerate() { + if *p <= 0.0 { + 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)) + .collect(); + + if sub_dets.is_empty() { + continue; + } + + let weight = if *p < 1.0 { ((1.0 - p) / p).ln() } else { 0.0 }; + let flips_obs = obs.contains(&(obs_idx as u32)); + let observables = if flips_obs { vec![0u32] } else { vec![] }; + + 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 num_sub = detector_map.len(); + let edges = DemMatchingGraph::merge_parallel_edges(edges); + + subgraphs.push(ObservableSubgraph { + observable_idx: obs_idx, + detector_map, + inverse_map, + graph: DemMatchingGraph { + edges, + num_detectors: num_sub, + num_observables: 1, + skipped_hyperedges: skipped, + detector_coords: Vec::new(), + }, + }); + } + + Ok(subgraphs) +} + +// ============================================================================ +// Decoder +// ============================================================================ + +/// Per-observable 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, + decoders: Vec>, + num_observables: usize, + sub_syndromes: Vec>, +} + +impl ObservableSubgraphDecoder { + /// Build from a DEM string, stabilizer coordinates, and inner decoder factory. + /// + /// # Errors + /// + /// Returns error if the DEM is malformed or the factory fails. + pub fn from_dem( + dem: &str, + stab_coords: &StabCoords, + factory: F, + ) -> Result + where + F: FnMut( + &DemMatchingGraph, + ) -> Result, DecoderError>, + { + Self::from_dem_windowed(dem, stab_coords, None, factory) + } + + pub fn from_dem_windowed( + dem: &str, + stab_coords: &StabCoords, + max_time_radius: MaxTimeRadius, + mut factory: F, + ) -> Result + where + F: FnMut( + &DemMatchingGraph, + ) -> Result, DecoderError>, + { + let subgraphs = partition_dem_by_observable_windowed(dem, stab_coords, max_time_radius)?; + 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 { + self.num_observables + } + + /// Access a subgraph. + #[must_use] + pub fn subgraph(&self, obs_idx: usize) -> Option<&ObservableSubgraph> { + self.subgraphs.get(obs_idx) + } + + /// Batch decode multiple syndromes, returning error count. + /// + /// For each subgraph, extracts all sub-syndromes into a flat buffer + /// and calls `decode_batch_to_observables` once — avoiding per-shot + /// reset overhead in decoders like `PyMatching`. + pub fn decode_count_batched( + &mut self, + syndromes: &[Vec], + expected_masks: &[u64], + ) -> 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]; + + for (i, (sg, dec)) in self + .subgraphs + .iter() + .zip(self.decoders.iter_mut()) + .enumerate() + { + let n = sg.detector_map.len(); + if n == 0 { + continue; + } + + // Build flat sub-syndrome buffer: num_shots × n bytes. + let mut flat = vec![0u8; num_shots * n]; + for (shot_idx, syn) in syndromes.iter().enumerate() { + let row = &mut flat[shot_idx * n..(shot_idx + 1) * n]; + for (sub_idx, &full_idx) in sg.detector_map.iter().enumerate() { + row[sub_idx] = if full_idx < syn.len() { + syn[full_idx] + } else { + 0 + }; + } + } + + // Batch decode this subgraph. + let sub_masks = dec.decode_batch_to_observables(&flat, num_shots, n)?; + + for (shot_idx, &sub_obs) in sub_masks.iter().enumerate() { + if sub_obs & 1 != 0 { + shot_obs[shot_idx] |= 1 << i; + } + } + } + + // Count errors. + let errors = shot_obs + .iter() + .zip(expected_masks.iter()) + .filter(|(predicted, expected)| predicted != expected) + .count(); + + Ok(errors) + } +} + +impl ObservableDecoder for ObservableSubgraphDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + let mut obs_mask = 0u64; + + for (i, (sg, dec)) in self + .subgraphs + .iter() + .zip(self.decoders.iter_mut()) + .enumerate() + { + let n = sg.detector_map.len(); + if n == 0 { + continue; + } + + let buf = &mut self.sub_syndromes[i]; + for (sub_idx, &full_idx) in sg.detector_map.iter().enumerate() { + buf[sub_idx] = if full_idx < syndrome.len() { + syndrome[full_idx] + } else { + 0 + }; + } + + let sub_obs = dec.decode_to_observables(&buf[..n])?; + + if sub_obs & 1 != 0 { + obs_mask |= 1 << i; + } + } + + Ok(obs_mask) + } +} + +/// Parallel per-observable subgraph decoder using rayon. +pub struct ParallelObservableSubgraphDecoder { + subgraphs: Vec, + decoders: Vec>>, +} + +impl ParallelObservableSubgraphDecoder { + /// Build from a DEM string, stabilizer coordinates, and inner decoder factory. + /// + /// # Errors + /// + /// Returns error if the DEM is malformed or the factory fails. + pub fn from_dem( + dem: &str, + stab_coords: &StabCoords, + mut factory: F, + ) -> Result + where + F: FnMut(&DemMatchingGraph) -> Result, DecoderError>, + { + let subgraphs = partition_dem_by_observable(dem, stab_coords)?; + + let mut decoders = Vec::with_capacity(subgraphs.len()); + for sg in &subgraphs { + decoders.push(std::sync::Mutex::new(factory(&sg.graph)?)); + } + + Ok(Self { + subgraphs, + decoders, + }) + } + + /// Decode using parallel subgraph decoding. + /// + /// # Errors + /// + /// Returns error if any subgraph decoder fails. + pub fn decode_parallel(&self, syndrome: &[u8]) -> Result { + use rayon::prelude::*; + + let results: Vec> = self + .subgraphs + .par_iter() + .zip(self.decoders.par_iter()) + .map(|(sg, dec_mutex)| { + let n = sg.detector_map.len(); + if n == 0 { + return Ok(false); + } + + let mut sub_syn = vec![0u8; n]; + for (sub_idx, &full_idx) in sg.detector_map.iter().enumerate() { + sub_syn[sub_idx] = if full_idx < syndrome.len() { + syndrome[full_idx] + } else { + 0 + }; + } + + let mut dec = dec_mutex.lock().unwrap(); + let sub_obs = dec.decode_to_observables(&sub_syn)?; + Ok(sub_obs & 1 != 0) + }) + .collect(); + + let mut obs_mask = 0u64; + for (i, result) in results.into_iter().enumerate() { + if result? { + obs_mask |= 1 << i; + } + } + Ok(obs_mask) + } +} + +// ============================================================================ +// Tests +// ============================================================================ + +#[cfg(test)] +mod tests { + use super::*; + + struct NullDecoder; + impl ObservableDecoder for NullDecoder { + fn decode_to_observables(&mut self, _: &[u8]) -> Result { + Ok(0) + } + } + + struct FixedDecoder(u64); + impl ObservableDecoder for FixedDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + if syndrome.iter().any(|&v| v != 0) { + Ok(self.0) + } else { + Ok(0) + } + } + } + + fn simple_stab_coords() -> StabCoords { + // Two qubits with non-overlapping X/Z positions. + vec![ + QubitStabCoords { + x_positions: vec![(1.0, 0.0)], + z_positions: vec![(0.0, 1.0)], + }, + QubitStabCoords { + x_positions: vec![(3.0, 0.0)], + z_positions: vec![(2.0, 1.0)], + }, + ] + } + + #[test] + fn test_classify_detector() { + let sc = simple_stab_coords(); + assert_eq!( + classify_detector(1.0, 0.0, &sc), + Some(DetectorGroup { + qubit_idx: 0, + stab_type: StabType::X + }), + ); + assert_eq!( + classify_detector(0.0, 1.0, &sc), + Some(DetectorGroup { + qubit_idx: 0, + stab_type: StabType::Z + }), + ); + assert_eq!( + classify_detector(3.0, 0.0, &sc), + Some(DetectorGroup { + qubit_idx: 1, + stab_type: StabType::X + }), + ); + assert_eq!(classify_detector(99.0, 99.0, &sc), None); + } + + #[test] + fn test_partition_simple() { + // Two detectors with coords, one observable. + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(0, 1, 0) D1\n", + "error(0.01) D0 D1 L0\n", + "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(); + assert_eq!(sgs.len(), 1); + // Boundary edge D0 L0 → D0 is qubit 0 X-type. + // Observing region = all qubit-0 X-type detectors = {D0}. + // But D0-D1 is also an observable mechanism, and D1 is qubit 0 Z-type. + // Since D1 is NOT in the same group as D0, it's excluded from the + // observing region. The edge D0-D1 projects to D0-boundary within + // the subgraph. + assert_eq!(sgs[0].detector_map, vec![0]); + } + + #[test] + fn test_partition_two_qubits() { + 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", // boundary: D0 = qubit 0 X + "error(0.01) D0 D1\n", // D0-D1 edge + "error(0.01) D2 L1\n", // boundary: D2 = qubit 1 X + "error(0.01) D2 D3\n", // D2-D3 edge + ); + let sc = simple_stab_coords(); + let sgs = partition_dem_by_observable(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 + } + + #[test] + fn test_decoder_routing() { + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(3, 0, 0) D1\n", + "error(0.01) D0 L0\n", + "error(0.01) D1 L1\n", + ); + let sc = simple_stab_coords(); + let mut dec = ObservableSubgraphDecoder::from_dem(dem, &sc, |_| { + Ok(Box::new(FixedDecoder(1)) as Box) + }) + .unwrap(); + + // Defect in obs 0's region only + let obs = dec.decode_to_observables(&[1, 0]).unwrap(); + assert_eq!(obs, 0b01); + + // Defect in obs 1's region only + let obs = dec.decode_to_observables(&[0, 1]).unwrap(); + assert_eq!(obs, 0b10); + } + + #[test] + fn test_parallel_decoder() { + let dem = concat!( + "detector(1, 0, 0) D0\n", + "detector(3, 0, 0) D1\n", + "error(0.01) D0 L0\n", + "error(0.01) D1 L1\n", + ); + let sc = simple_stab_coords(); + let dec = ParallelObservableSubgraphDecoder::from_dem(dem, &sc, |_| { + Ok(Box::new(NullDecoder) as Box) + }) + .unwrap(); + + let obs = dec.decode_parallel(&[0, 0]).unwrap(); + assert_eq!(obs, 0); + } +} diff --git a/crates/pecos-decoder-core/src/pauli_frame.rs b/crates/pecos-decoder-core/src/pauli_frame.rs new file mode 100644 index 000000000..c5182c999 --- /dev/null +++ b/crates/pecos-decoder-core/src/pauli_frame.rs @@ -0,0 +1,259 @@ +// 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 accumulator for real-time QEC. +//! +//! In a real QEC system, the decoder runs once per QEC cycle, producing +//! an observable mask. These masks accumulate into a Pauli frame that +//! tracks the net logical correction. The frame is consumed only when +//! a logical operation requires it (T-gate injection, logical measurement). +//! +//! # Example +//! +//! ``` +//! use pecos_decoder_core::{DecoderError, ObservableDecoder}; +//! 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) +//! } +//! } +//! +//! let mut frame = PauliFrameAccumulator::new(Box::new(FixedDecoder(0b01))); +//! +//! // QEC cycles +//! for syndrome in [&[1, 0][..], &[0, 1][..]] { +//! frame.decode_cycle(syndrome).unwrap(); +//! } +//! assert_eq!(frame.current_frame(), 0b00); +//! +//! // At logical measurement: consume frame +//! let correction = frame.consume_frame(); +//! let raw_measurement = 1; +//! let logical_result = raw_measurement ^ (correction & 1); +//! assert_eq!(logical_result, 1); +//! ``` + +use crate::ObservableDecoder; +use crate::errors::DecoderError; + +/// Accumulates Pauli frame corrections across QEC cycles. +/// +/// Wraps any `ObservableDecoder` and XORs each cycle's observable mask +/// into a running frame. The frame represents the net logical correction +/// needed at the current point in the computation. +pub struct PauliFrameAccumulator { + decoder: Box, + frame: u64, + cycle_count: usize, +} + +impl PauliFrameAccumulator { + /// Create from any observable decoder. + #[must_use] + pub fn new(decoder: Box) -> Self { + Self { + decoder, + frame: 0, + cycle_count: 0, + } + } + + /// Decode one QEC cycle's syndrome and accumulate into the frame. + /// + /// # Errors + /// + /// Returns `DecoderError` if the inner decoder fails. + pub fn decode_cycle(&mut self, syndrome: &[u8]) -> Result { + let obs = self.decoder.decode_to_observables(syndrome)?; + self.frame ^= obs; + self.cycle_count += 1; + Ok(obs) + } + + /// Current accumulated Pauli frame (does not reset). + #[must_use] + pub fn current_frame(&self) -> u64 { + self.frame + } + + /// Consume the frame: returns the accumulated mask and resets to zero. + /// + /// Call this at logical operations (T-gate, measurement) to get the + /// correction and start fresh for the next logical cycle. + pub fn consume_frame(&mut self) -> u64 { + let f = self.frame; + self.frame = 0; + self.cycle_count = 0; + f + } + + /// Number of QEC cycles since last reset/consume. + #[must_use] + pub fn cycle_count(&self) -> usize { + self.cycle_count + } + + /// Manually flip a frame bit (e.g., for deterministic corrections). + pub fn flip_bit(&mut self, bit: u32) { + self.frame ^= 1u64 << bit; + } + + /// Direct access to the frame bits. + #[must_use] + pub fn frame_mut(&mut self) -> &mut u64 { + &mut self.frame + } + + /// Access the inner decoder. + pub fn decoder_mut(&mut self) -> &mut dyn ObservableDecoder { + &mut *self.decoder + } +} + +/// Propagate Pauli frames through a transversal CNOT. +/// +/// When a logical CNOT is applied from control to target: +/// - X errors propagate: control → target (`X_c` → `X_c` ⊗ `X_t`) +/// - Z errors propagate: target → control (`Z_t` → `Z_c` ⊗ `Z_t`) +/// +/// For observable masks (bit k = observable k): +/// - X-type observables on control propagate to target +/// - Z-type observables on target propagate to control +/// +/// `x_obs_mask`: which observable bits are X-type (propagate forward) +/// `z_obs_mask`: which observable bits are Z-type (propagate backward) +pub fn propagate_cnot_frames( + control: &mut PauliFrameAccumulator, + target: &mut PauliFrameAccumulator, + x_obs_mask: u64, + z_obs_mask: u64, +) { + let ctrl_frame = control.current_frame(); + let tgt_frame = target.current_frame(); + + // X-type bits on control propagate to target: target ^= control & x_mask + *target.frame_mut() ^= ctrl_frame & x_obs_mask; + + // Z-type bits on target propagate to control: control ^= target & z_mask + *control.frame_mut() ^= tgt_frame & z_obs_mask; +} + +/// Propagate Pauli frame through a logical S gate (phase gate). +/// +/// S gate: X → Y = iXZ, Z → Z. For the frame: +/// - Z-type bits are unchanged +/// - X-type bits that are set also flip the corresponding Z-type bit +pub fn propagate_s_gate_frame(frame: &mut PauliFrameAccumulator, x_obs_mask: u64, z_obs_mask: u64) { + let f = frame.current_frame(); + // X-type corrections also induce Z-type corrections after S gate + *frame.frame_mut() ^= (f & x_obs_mask) & z_obs_mask; +} + +/// Propagate Pauli frame through a logical Hadamard. +/// +/// H gate: X ↔ Z. Swaps X-type and Z-type frame bits. +pub fn propagate_h_gate_frame(frame: &mut PauliFrameAccumulator, x_obs_mask: u64, z_obs_mask: u64) { + let f = frame.current_frame(); + let x_bits = f & x_obs_mask; + let z_bits = f & z_obs_mask; + // Clear both, then swap + *frame.frame_mut() &= !(x_obs_mask | z_obs_mask); + // X bits go to Z positions, Z bits go to X positions + // (This assumes x_obs_mask and z_obs_mask don't overlap and have + // matching bit positions. For a single logical qubit with obs 0 = X + // and obs 1 = Z: x_mask=0b01, z_mask=0b10, swap bits 0 and 1.) + *frame.frame_mut() |= if x_bits != 0 { z_obs_mask } else { 0 }; + *frame.frame_mut() |= if z_bits != 0 { x_obs_mask } else { 0 }; +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FixedDecoder(u64); + impl ObservableDecoder for FixedDecoder { + fn decode_to_observables(&mut self, _: &[u8]) -> Result { + Ok(self.0) + } + } + + #[test] + fn test_accumulate_xor() { + let mut frame = PauliFrameAccumulator::new(Box::new(FixedDecoder(0b01))); + frame.decode_cycle(&[]).unwrap(); + assert_eq!(frame.current_frame(), 0b01); + frame.decode_cycle(&[]).unwrap(); + assert_eq!(frame.current_frame(), 0b00); // XOR cancels + frame.decode_cycle(&[]).unwrap(); + assert_eq!(frame.current_frame(), 0b01); + assert_eq!(frame.cycle_count(), 3); + } + + #[test] + fn test_consume_resets() { + let mut frame = PauliFrameAccumulator::new(Box::new(FixedDecoder(0b11))); + frame.decode_cycle(&[]).unwrap(); + assert_eq!(frame.consume_frame(), 0b11); + assert_eq!(frame.current_frame(), 0); + assert_eq!(frame.cycle_count(), 0); + } + + #[test] + fn test_flip_bit() { + let mut frame = PauliFrameAccumulator::new(Box::new(FixedDecoder(0))); + frame.flip_bit(2); + assert_eq!(frame.current_frame(), 0b100); + frame.flip_bit(2); + assert_eq!(frame.current_frame(), 0); + } + + #[test] + fn test_cnot_frame_propagation() { + // Two logical qubits with obs bit 0 = X-type, bit 1 = Z-type. + let mut ctrl = PauliFrameAccumulator::new(Box::new(FixedDecoder(0))); + let mut tgt = PauliFrameAccumulator::new(Box::new(FixedDecoder(0))); + + // Control has X correction (bit 0). + ctrl.flip_bit(0); + assert_eq!(ctrl.current_frame(), 0b01); + assert_eq!(tgt.current_frame(), 0b00); + + // CNOT: X on control propagates to target. + propagate_cnot_frames(&mut ctrl, &mut tgt, 0b01, 0b10); + assert_eq!(ctrl.current_frame(), 0b01); // unchanged + assert_eq!(tgt.current_frame(), 0b01); // X propagated + + // Now target has Z correction (bit 1). + tgt.flip_bit(1); + assert_eq!(tgt.current_frame(), 0b11); + + // CNOT: Z on target propagates to control. + propagate_cnot_frames(&mut ctrl, &mut tgt, 0b01, 0b10); + assert_eq!(ctrl.current_frame(), 0b11); // Z propagated back + } + + #[test] + fn test_hadamard_frame() { + let mut frame = PauliFrameAccumulator::new(Box::new(FixedDecoder(0))); + // Set X correction (bit 0). + frame.flip_bit(0); + assert_eq!(frame.current_frame(), 0b01); + + // Hadamard: X ↔ Z (swap bits 0 and 1). + propagate_h_gate_frame(&mut frame, 0b01, 0b10); + assert_eq!(frame.current_frame(), 0b10); // X became Z + } +} diff --git a/crates/pecos-decoder-core/src/perturbed.rs b/crates/pecos-decoder-core/src/perturbed.rs new file mode 100644 index 000000000..a2fb1e81e --- /dev/null +++ b/crates/pecos-decoder-core/src/perturbed.rs @@ -0,0 +1,213 @@ +// 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. + +//! Perturbed-weight ensemble decoder. +//! +//! Builds K decoders from K perturbed copies of a DEM, then majority-votes +//! on each observable bit per shot. The weight perturbation creates diversity +//! in matching decisions, and the ensemble smooths out individual mistakes. +//! +//! At d=5 with K=15 sigma=0.7 `PyMatching` inner, this gives ~5% fewer errors +//! than a single correlated `PyMatching` — a practical accuracy improvement +//! over the state of the art. + +use crate::ObservableDecoder; +use crate::ensemble::EnsembleDecoder; +use crate::errors::DecoderError; + +/// Configuration for the perturbed-weight ensemble. +#[derive(Debug, Clone)] +pub struct PerturbedConfig { + /// Number of ensemble members (including the unperturbed anchor). + pub k: usize, + /// Standard deviation of the log-normal weight perturbation. + /// Each error(p) becomes error(p * exp(N(0, sigma^2))). + pub sigma: f64, + /// RNG seed for reproducibility. + pub seed: u64, +} + +impl Default for PerturbedConfig { + fn default() -> Self { + Self { + k: 15, + sigma: 0.7, + seed: 42, + } + } +} + +/// Perturb error probabilities in a DEM string by multiplicative log-normal noise. +pub fn perturb_dem(dem: &str, sigma: f64, rng: &mut dyn FnMut() -> f64) -> String { + use std::fmt::Write; + let mut out = String::with_capacity(dem.len()); + for line in dem.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("error(") + && let Some(close) = rest.find(')') + && let Ok(p) = rest[..close].parse::() + { + let u1 = rng().max(1e-10); + let u2 = rng(); + let z = (-2.0_f64 * u1.ln()).sqrt() * (2.0_f64 * std::f64::consts::PI * u2).cos(); + let factor = (sigma * z).exp(); + let p_new = (p * factor).clamp(1e-15, 0.499); + let _ = write!(out, "error({p_new})"); + out.push_str(&rest[close..]); + out.push('\n'); + continue; + } + out.push_str(trimmed); + out.push('\n'); + } + out +} + +/// Build a perturbed-weight ensemble from a DEM and a decoder factory. +/// +/// Creates K decoders: one unperturbed anchor + (K-1) perturbed copies. +/// Returns an `EnsembleDecoder` with majority voting. +/// +/// The factory is called once per member with a (possibly perturbed) DEM string. +/// +/// # Errors +/// +/// Returns `DecoderError` if the factory fails on the unperturbed DEM. +pub fn build_perturbed_ensemble( + dem: &str, + config: &PerturbedConfig, + mut factory: F, +) -> Result +where + F: FnMut(&str) -> Result, DecoderError>, +{ + let mut members: Vec> = Vec::with_capacity(config.k); + + // Unperturbed anchor + members.push(factory(dem)?); + + // Use PecosRng for high-quality perturbation randomness. + let mut rng = pecos_random::PecosRng::seed_from_u64(config.seed); + let mut next_f64 = move || -> f64 { rng.next_f64() }; + + for _ in 1..config.k { + let perturbed = perturb_dem(dem, config.sigma, &mut next_f64); + if let Ok(dec) = factory(&perturbed) { + members.push(dec); + } + } + + Ok(EnsembleDecoder::new(members)) +} + +/// Build a parallel perturbed-weight ensemble (rayon-accelerated). +/// +/// Same as `build_perturbed_ensemble` but returns a `ParallelEnsembleDecoder` +/// that decodes all K members concurrently. Factory must produce `Send` decoders. +/// +/// # Errors +/// +/// Returns `DecoderError` if the factory fails on the unperturbed DEM. +pub fn build_parallel_perturbed_ensemble( + dem: &str, + config: &PerturbedConfig, + mut factory: F, +) -> Result +where + F: FnMut(&str) -> Result, DecoderError>, +{ + let mut members: Vec> = Vec::with_capacity(config.k); + members.push(factory(dem)?); + + let mut rng = pecos_random::PecosRng::seed_from_u64(config.seed); + let mut next_f64 = move || -> f64 { rng.next_f64() }; + + for _ in 1..config.k { + let perturbed = perturb_dem(dem, config.sigma, &mut next_f64); + if let Ok(dec) = factory(&perturbed) { + members.push(dec); + } + } + + Ok(crate::ensemble::ParallelEnsembleDecoder::new(members)) +} + +#[cfg(test)] +mod tests { + use super::*; + + const SIMPLE_DEM: &str = "error(0.1) D0 D1 L0\nerror(0.05) D1\n"; + + #[test] + fn test_perturb_dem_preserves_structure() { + let mut i = 0u64; + let mut rng = || -> f64 { + i += 1; + // Deterministic: 0.5, 0.6, 0.7, ... + 0.5 + (i as f64) * 0.01 + }; + let perturbed = perturb_dem(SIMPLE_DEM, 0.5, &mut rng); + // Should still have error() lines. + assert!(perturbed.contains("error(")); + // Should have D0, D1, L0. + assert!(perturbed.contains("D0")); + assert!(perturbed.contains("D1")); + assert!(perturbed.contains("L0")); + // Probabilities should be different from original. + assert!(!perturbed.contains("error(0.1)")); + } + + #[test] + fn test_perturb_dem_clamps_probability() { + // With sigma=10, some probabilities could go very high or low. + let mut i = 0u64; + let mut rng = || -> f64 { + i += 1; + 0.999 // Will push exp(10 * z) very high + }; + let perturbed = perturb_dem(SIMPLE_DEM, 10.0, &mut rng); + // Should still parse (probabilities clamped to 0.499 max). + for line in perturbed.lines() { + let trimmed = line.trim(); + if let Some(rest) = trimmed.strip_prefix("error(") + && let Some(close) = rest.find(')') + { + let p: f64 = rest[..close].parse().unwrap(); + assert!(p > 0.0 && p < 0.5, "p={p} out of bounds"); + } + } + } + + #[test] + fn test_build_perturbed_ensemble_k1() { + let config = PerturbedConfig { + k: 1, + sigma: 0.5, + seed: 42, + }; + let ensemble = build_perturbed_ensemble(SIMPLE_DEM, &config, |_dem| { + // Trivial decoder that always returns 0. + struct Zero; + impl crate::ObservableDecoder for Zero { + fn decode_to_observables( + &mut self, + _: &[u8], + ) -> Result { + Ok(0) + } + } + Ok(Box::new(Zero)) + }); + assert!(ensemble.is_ok()); + assert_eq!(ensemble.unwrap().len(), 1); + } +} diff --git a/crates/pecos-decoder-core/src/preprocessor.rs b/crates/pecos-decoder-core/src/preprocessor.rs new file mode 100644 index 000000000..19505fcc9 --- /dev/null +++ b/crates/pecos-decoder-core/src/preprocessor.rs @@ -0,0 +1,199 @@ +// 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. + +//! Syndrome preprocessor for real-system QEC decoding. +//! +//! Sits between the hardware readout and the decoder. Responsibilities: +//! - Convert leakage flags (from PECOS's `MeasureLeaked` simulation or +//! neutral atom loss detection) into erasure edge indices for the decoder +//! - Detect syndrome anomalies (excessive weight, all-ones, etc.) +//! - Validate syndrome dimensions +//! +//! # Leakage-to-Erasure Pipeline +//! +//! Neutral atoms: atom loss is detected per-qubit. Each lost qubit +//! affects specific error mechanisms (edges in the matching graph). +//! The preprocessor maps qubit loss → affected DEM edges → erasure +//! indices for `ObservableErasureDecoder`. +//! +//! The mapping is built at construction from the DEM's detector +//! coordinates or a user-provided qubit-to-edge mapping. + +/// Anomaly detected in a syndrome. +#[derive(Debug, Clone)] +pub enum SyndromeAnomaly { + /// More defects than expected (possible burst error or readout failure). + ExcessiveWeight { weight: usize, threshold: usize }, + /// All detectors fired (likely readout failure, not a real syndrome). + AllOnes, + /// Wrong syndrome length. + WrongLength { expected: usize, actual: usize }, +} + +/// Preprocessed syndrome ready for the decoder. +#[derive(Debug, Clone)] +pub struct ProcessedSyndrome { + /// The syndrome (possibly with leakage-affected bits cleared/modified). + pub syndrome: Vec, + /// DEM edge indices known to be erased (from leakage detection). + pub erasure_edges: Vec, + /// Anomaly if detected, None if syndrome looks normal. + pub anomaly: Option, +} + +/// Syndrome preprocessor. +pub struct SyndromePreprocessor { + num_detectors: usize, + /// Maximum expected syndrome weight before flagging anomaly. + /// Set to 0 to disable (default). + weight_threshold: usize, + /// Qubit index → list of DEM edge indices affected by that qubit's loss. + /// Built from the DEM structure at construction time. + qubit_to_erasure_edges: Vec>, +} + +impl SyndromePreprocessor { + /// Create with basic syndrome validation. + #[must_use] + pub fn new(num_detectors: usize) -> Self { + Self { + num_detectors, + weight_threshold: 0, + qubit_to_erasure_edges: Vec::new(), + } + } + + /// Set the maximum expected syndrome weight for anomaly detection. + pub fn set_weight_threshold(&mut self, threshold: usize) { + self.weight_threshold = threshold; + } + + /// Set the qubit-to-erasure-edge mapping for leakage conversion. + /// + /// `mapping[qubit_idx]` = list of DEM edge indices affected by that qubit. + /// Built from the DEM: for each edge, find which data qubits it involves. + pub fn set_erasure_mapping(&mut self, mapping: Vec>) { + self.qubit_to_erasure_edges = mapping; + } + + /// Preprocess a raw syndrome with optional leakage flags. + /// + /// `leakage_flags`: one byte per qubit, nonzero = qubit leaked/lost. + /// Converts leaked qubits to erasure edge indices via the mapping. + #[must_use] + pub fn preprocess( + &self, + raw_syndrome: &[u8], + leakage_flags: Option<&[u8]>, + ) -> ProcessedSyndrome { + let mut anomaly = None; + + // Validate length. + if raw_syndrome.len() != self.num_detectors { + return ProcessedSyndrome { + syndrome: raw_syndrome.to_vec(), + erasure_edges: Vec::new(), + anomaly: Some(SyndromeAnomaly::WrongLength { + expected: self.num_detectors, + actual: raw_syndrome.len(), + }), + }; + } + + // Check weight. + let weight = raw_syndrome.iter().filter(|&&v| v != 0).count(); + if weight == self.num_detectors && self.num_detectors > 0 { + anomaly = Some(SyndromeAnomaly::AllOnes); + } else if self.weight_threshold > 0 && weight > self.weight_threshold { + anomaly = Some(SyndromeAnomaly::ExcessiveWeight { + weight, + threshold: self.weight_threshold, + }); + } + + // Convert leakage flags to erasure edges. + let mut erasure_edges = Vec::new(); + if let Some(flags) = leakage_flags { + for (qubit, &leaked) in flags.iter().enumerate() { + if leaked != 0 && qubit < self.qubit_to_erasure_edges.len() { + erasure_edges.extend_from_slice(&self.qubit_to_erasure_edges[qubit]); + } + } + erasure_edges.sort_unstable(); + erasure_edges.dedup(); + } + + ProcessedSyndrome { + syndrome: raw_syndrome.to_vec(), + erasure_edges, + anomaly, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_basic_preprocessing() { + let pp = SyndromePreprocessor::new(4); + let result = pp.preprocess(&[0, 1, 0, 1], None); + assert!(result.anomaly.is_none()); + assert!(result.erasure_edges.is_empty()); + } + + #[test] + fn test_wrong_length() { + let pp = SyndromePreprocessor::new(4); + let result = pp.preprocess(&[0, 1], None); + assert!(matches!( + result.anomaly, + Some(SyndromeAnomaly::WrongLength { .. }) + )); + } + + #[test] + fn test_excessive_weight() { + let mut pp = SyndromePreprocessor::new(4); + pp.set_weight_threshold(2); + let result = pp.preprocess(&[1, 1, 1, 0], None); + assert!(matches!( + result.anomaly, + Some(SyndromeAnomaly::ExcessiveWeight { weight: 3, .. }) + )); + } + + #[test] + fn test_all_ones() { + let pp = SyndromePreprocessor::new(3); + let result = pp.preprocess(&[1, 1, 1], None); + assert!(matches!(result.anomaly, Some(SyndromeAnomaly::AllOnes))); + } + + #[test] + fn test_leakage_to_erasure() { + let mut pp = SyndromePreprocessor::new(4); + // Qubit 0 affects edges [0, 2], qubit 1 affects edge [1] + pp.set_erasure_mapping(vec![vec![0, 2], vec![1]]); + let result = pp.preprocess(&[0, 1, 0, 0], Some(&[1, 0])); // qubit 0 leaked + assert_eq!(result.erasure_edges, vec![0, 2]); + } + + #[test] + fn test_multiple_leakage() { + let mut pp = SyndromePreprocessor::new(4); + pp.set_erasure_mapping(vec![vec![0, 2], vec![1, 2]]); // edge 2 shared + let result = pp.preprocess(&[0, 0, 0, 0], Some(&[1, 1])); // both leaked + assert_eq!(result.erasure_edges, vec![0, 1, 2]); // deduped + } +} diff --git a/crates/pecos-decoder-core/src/results.rs b/crates/pecos-decoder-core/src/results.rs index ac0f85a4e..96528cbfc 100644 --- a/crates/pecos-decoder-core/src/results.rs +++ b/crates/pecos-decoder-core/src/results.rs @@ -61,6 +61,14 @@ pub trait DecodingResultTrait { /// Whether the decoding was successful fn is_successful(&self) -> bool; + /// Get the raw correction/decoding vector (one entry per error mechanism). + /// + /// For check-matrix decoders this is the estimated error pattern. + /// For MWPM decoders this may be the observable prediction directly. + fn correction(&self) -> &[u8] { + &[] + } + /// Get the cost of the decoding (if available) fn cost(&self) -> Option { None diff --git a/crates/pecos-decoder-core/src/streaming.rs b/crates/pecos-decoder-core/src/streaming.rs new file mode 100644 index 000000000..3ab3757a3 --- /dev/null +++ b/crates/pecos-decoder-core/src/streaming.rs @@ -0,0 +1,54 @@ +// 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. + +//! Streaming decoder trait for real-time QEC. +//! +//! Accepts syndrome data incrementally (round by round) and emits +//! partial observable corrections as windows complete. + +use crate::errors::DecoderError; + +/// Streaming decoder that accepts syndrome data incrementally. +/// +/// For real-time decoding where syndrome arrives round-by-round. +/// The decoder manages windows internally and emits committed +/// corrections as each window's core region becomes decodable. +pub trait StreamingDecoder { + /// Feed one round of detection events. + /// + /// `round` is the time coordinate (0-indexed). + /// `detectors` contains `(detector_index, value)` pairs for this round. + /// + /// Returns any newly committed observable corrections as a bitmask. + /// Returns 0 if no window completed this round. + /// + /// # Errors + /// + /// Returns `DecoderError` if window decoding fails. + fn feed_round(&mut self, round: usize, detectors: &[(u32, u8)]) -> Result; + + /// Signal that no more rounds will arrive. + /// + /// Forces decode of any remaining buffered windows and returns + /// the final observable correction for uncommitted windows. + /// + /// # Errors + /// + /// Returns `DecoderError` if window decoding fails. + fn flush(&mut self) -> Result; + + /// Total observable mask accumulated so far (XOR of all committed corrections). + fn accumulated_obs(&self) -> u64; + + /// Reset for the next shot (clear syndrome buffer and accumulated state). + fn reset(&mut self); +} diff --git a/crates/pecos-decoder-core/src/telemetry.rs b/crates/pecos-decoder-core/src/telemetry.rs new file mode 100644 index 000000000..395fc6940 --- /dev/null +++ b/crates/pecos-decoder-core/src/telemetry.rs @@ -0,0 +1,200 @@ +// 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. + +//! Decoder telemetry wrapper for real-time monitoring. +//! +//! Wraps any `ObservableDecoder` with transparent per-decode latency +//! measurement and statistics tracking. Useful for monitoring decoder +//! health in a real QEC system. + +use crate::ObservableDecoder; +use crate::errors::DecoderError; +use std::collections::VecDeque; +use std::time::Instant; + +/// Live decoder statistics. +#[derive(Debug, Clone)] +pub struct DecoderTelemetry { + /// Total decodes performed. + pub decode_count: u64, + /// Total decode time in nanoseconds. + pub total_decode_ns: u64, + /// Sum of syndrome weights (number of defects per decode). + pub syndrome_weight_sum: u64, + /// Number of decodes that produced nonzero observable. + pub nonzero_observable_count: u64, + /// Maximum single-decode latency in nanoseconds. + pub max_decode_ns: u64, + /// Recent decode latencies for rolling statistics. + pub recent_latencies_ns: VecDeque, + /// Maximum size of the rolling window. + window_size: usize, +} + +impl DecoderTelemetry { + fn new(window_size: usize) -> Self { + Self { + decode_count: 0, + total_decode_ns: 0, + syndrome_weight_sum: 0, + nonzero_observable_count: 0, + max_decode_ns: 0, + recent_latencies_ns: VecDeque::with_capacity(window_size), + window_size, + } + } + + fn record(&mut self, latency_ns: u64, syndrome_weight: u64, obs_nonzero: bool) { + self.decode_count += 1; + self.total_decode_ns += latency_ns; + self.syndrome_weight_sum += syndrome_weight; + if obs_nonzero { + self.nonzero_observable_count += 1; + } + if latency_ns > self.max_decode_ns { + self.max_decode_ns = latency_ns; + } + if self.recent_latencies_ns.len() >= self.window_size { + self.recent_latencies_ns.pop_front(); + } + self.recent_latencies_ns.push_back(latency_ns); + } + + /// Average decode latency in nanoseconds. + #[must_use] + pub fn avg_decode_ns(&self) -> f64 { + if self.decode_count == 0 { + 0.0 + } else { + self.total_decode_ns as f64 / self.decode_count as f64 + } + } + + /// Average syndrome weight (defects per decode). + #[must_use] + pub fn avg_syndrome_weight(&self) -> f64 { + if self.decode_count == 0 { + 0.0 + } else { + self.syndrome_weight_sum as f64 / self.decode_count as f64 + } + } + + /// Fraction of decodes that produced a nonzero observable (logical correction). + #[must_use] + pub fn correction_rate(&self) -> f64 { + if self.decode_count == 0 { + 0.0 + } else { + self.nonzero_observable_count as f64 / self.decode_count as f64 + } + } + + /// P99 latency from recent window in nanoseconds. + #[must_use] + pub fn p99_latency_ns(&self) -> u64 { + if self.recent_latencies_ns.is_empty() { + return 0; + } + let mut sorted: Vec = self.recent_latencies_ns.iter().copied().collect(); + sorted.sort_unstable(); + let idx = (sorted.len() * 99 / 100).min(sorted.len() - 1); + sorted[idx] + } +} + +/// Telemetry-instrumented decoder. +/// +/// Wraps any `ObservableDecoder` with transparent latency and statistics +/// tracking. The inner decoder's behavior is unchanged. +pub struct TelemetryDecoder { + inner: Box, + /// Live telemetry data. Read via `telemetry()`. + stats: DecoderTelemetry, +} + +impl TelemetryDecoder { + /// Create with a rolling window of `window_size` recent latencies. + #[must_use] + pub fn new(inner: Box, window_size: usize) -> Self { + Self { + inner, + stats: DecoderTelemetry::new(window_size), + } + } + + /// Access the telemetry data. + #[must_use] + pub fn telemetry(&self) -> &DecoderTelemetry { + &self.stats + } + + /// Reset all statistics. + pub fn reset_telemetry(&mut self) { + self.stats = DecoderTelemetry::new(self.stats.window_size); + } +} + +impl ObservableDecoder for TelemetryDecoder { + fn decode_to_observables(&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 elapsed_ns = start.elapsed().as_nanos() as u64; + self.stats.record(elapsed_ns, syndrome_weight, obs != 0); + Ok(obs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + struct FixedDecoder(u64); + impl ObservableDecoder for FixedDecoder { + fn decode_to_observables(&mut self, _: &[u8]) -> Result { + Ok(self.0) + } + } + + #[test] + fn test_telemetry_counts() { + let mut dec = TelemetryDecoder::new(Box::new(FixedDecoder(1)), 100); + dec.decode_to_observables(&[0, 1, 0]).unwrap(); + dec.decode_to_observables(&[0, 0, 0]).unwrap(); + + let t = dec.telemetry(); + assert_eq!(t.decode_count, 2); + assert_eq!(t.nonzero_observable_count, 2); // FixedDecoder always returns 1 + assert_eq!(t.syndrome_weight_sum, 1); // only first syndrome has weight 1 + } + + #[test] + fn test_telemetry_latency() { + let mut dec = TelemetryDecoder::new(Box::new(FixedDecoder(0)), 10); + for _ in 0..5 { + dec.decode_to_observables(&[]).unwrap(); + } + let t = dec.telemetry(); + assert_eq!(t.decode_count, 5); + assert!(t.avg_decode_ns() >= 0.0); + assert!(t.p99_latency_ns() > 0); + } + + #[test] + fn test_reset() { + let mut dec = TelemetryDecoder::new(Box::new(FixedDecoder(0)), 10); + dec.decode_to_observables(&[]).unwrap(); + dec.reset_telemetry(); + assert_eq!(dec.telemetry().decode_count, 0); + } +} diff --git a/crates/pecos-decoder-core/src/two_pass_decoder.rs b/crates/pecos-decoder-core/src/two_pass_decoder.rs new file mode 100644 index 000000000..8c7298855 --- /dev/null +++ b/crates/pecos-decoder-core/src/two_pass_decoder.rs @@ -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. + +//! Two-pass correlated decoder using pre-computed correlation table. +//! +//! Wraps any `MatchingDecoder` with a two-pass decode: +//! 1. First pass: standard decode to identify matched edges +//! 2. Apply conditional weight adjustments from `CorrelationTable` +//! 3. Second pass: re-decode with adjusted weights +//! +//! This is the decoder-agnostic equivalent of `PyMatching`'s correlated matching. + +use crate::correlated_decoder::MatchingDecoder; +use crate::correlation_table::CorrelationTable; +use crate::errors::DecoderError; + +/// Two-pass correlated decoder. +/// +/// Uses a pre-computed `CorrelationTable` (from DEM decomposition) to +/// adjust edge weights between the first and second decode passes. +/// Works with any decoder implementing `MatchingDecoder`. +pub struct TwoPassDecoder { + inner: D, + correlation_table: CorrelationTable, + base_weights: Vec, + /// Reusable buffer for adjusted weights (avoids per-shot allocation). + adjusted_weights: Vec, +} + +impl TwoPassDecoder { + /// Create a new two-pass decoder. + /// + /// `base_weights` should have one entry per edge in the matching graph, + /// in the same order as the decoder's edge indices. + pub fn new(inner: D, base_weights: Vec, correlation_table: CorrelationTable) -> Self { + let n = base_weights.len(); + Self { + inner, + correlation_table, + adjusted_weights: vec![0.0; n], + base_weights, + } + } + + /// Whether the correlation table has any correlations to exploit. + #[must_use] + pub fn has_correlations(&self) -> bool { + self.correlation_table.has_correlations() + } +} + +impl crate::ObservableDecoder for TwoPassDecoder { + fn decode_to_observables(&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); + } + + // First pass: decode to get matched edges + let (_, matched_edges) = self.inner.decode_with_matching(syndrome)?; + + // Apply correlation adjustments: for each matched edge, look up + // its implied weights and lower correlated edges' weights. + self.adjusted_weights.copy_from_slice(&self.base_weights); + for &edge_idx in &matched_edges { + if edge_idx < self.correlation_table.implied_weights.len() { + for iw in &self.correlation_table.implied_weights[edge_idx] { + // Only lower the weight (make the correlated edge more likely) + if iw.conditional_weight < self.adjusted_weights[iw.target_edge_idx] { + self.adjusted_weights[iw.target_edge_idx] = iw.conditional_weight; + } + } + } + } + + // Second pass: decode with adjusted weights + let (mask, _) = self + .inner + .decode_with_weights(syndrome, &self.adjusted_weights)?; + Ok(mask) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::ObservableDecoder; + use crate::correlation_table::ImpliedWeight; + + struct MockDecoder { + num_edges: usize, + calls: std::cell::RefCell, + } + + impl MatchingDecoder for MockDecoder { + fn decode_with_matching( + &mut self, + _syndrome: &[u8], + ) -> Result<(u64, Vec), DecoderError> { + *self.calls.borrow_mut() += 1; + Ok((0, vec![0])) // Always match edge 0 + } + + fn decode_with_weights( + &mut self, + _syndrome: &[u8], + _weights: &[f64], + ) -> Result<(u64, Vec), DecoderError> { + *self.calls.borrow_mut() += 1; + Ok((1, vec![0, 1])) // Different result with adjusted weights + } + + fn num_edges(&self) -> usize { + self.num_edges + } + } + + #[test] + fn test_two_pass_with_correlations() { + let mock = MockDecoder { + num_edges: 3, + calls: std::cell::RefCell::new(0), + }; + let weights = vec![5.0, 5.0, 5.0]; + let table = CorrelationTable { + implied_weights: vec![ + vec![ImpliedWeight { + target_edge_idx: 1, + conditional_weight: 2.0, + }], + vec![], + vec![], + ], + num_edges: 3, + }; + + let mut decoder = TwoPassDecoder::new(mock, weights, table); + let mask = decoder.decode_to_observables(&[1, 0, 0]).unwrap(); + + // Second pass should be called (returns mask=1) + assert_eq!(mask, 1); + // Two calls: first pass + second pass + assert_eq!(*decoder.inner.calls.borrow(), 2); + } + + #[test] + fn test_two_pass_no_correlations() { + let mock = MockDecoder { + num_edges: 3, + calls: std::cell::RefCell::new(0), + }; + let weights = vec![5.0, 5.0, 5.0]; + let table = CorrelationTable { + implied_weights: vec![vec![], vec![], vec![]], + num_edges: 3, + }; + + let mut decoder = TwoPassDecoder::new(mock, weights, table); + let mask = decoder.decode_to_observables(&[1, 0, 0]).unwrap(); + + // No correlations: single pass only (returns mask=0) + assert_eq!(mask, 0); + assert_eq!(*decoder.inner.calls.borrow(), 1); + } +} diff --git a/crates/pecos-decoder-core/src/windowed_osd.rs b/crates/pecos-decoder-core/src/windowed_osd.rs new file mode 100644 index 000000000..00e575ffe --- /dev/null +++ b/crates/pecos-decoder-core/src/windowed_osd.rs @@ -0,0 +1,294 @@ +// 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-observable 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::observable_subgraph::{ObservableSubgraphDecoder, StabCoords}; + +/// Configuration for windowed OSD. +#[derive(Debug, Clone)] +pub struct WindowedOsdConfig { + /// Core window size in time steps. + pub step: usize, + /// Buffer size on each side (0 = non-overlapping). + pub buffer: usize, +} + +impl Default for WindowedOsdConfig { + fn default() -> Self { + Self { step: 8, buffer: 4 } + } +} + +/// A single time window with its own OSD. +pub struct OsdWindow { + decoder: ObservableSubgraphDecoder, + /// 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 OSD. +/// The observing region within each window is naturally bounded, +/// preventing the scaling degradation seen at deep circuits. +pub struct WindowedOsdDecoder { + pub windows: Vec, + _num_detectors: usize, + /// Reusable window syndrome buffer + window_syn: Vec, +} + +impl WindowedOsdDecoder { + /// 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: &WindowedOsdConfig, + 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 OSD + let full_osd = + ObservableSubgraphDecoder::from_dem(dem, stab_coords, &mut inner_factory)?; + return Ok(Self { + windows: vec![OsdWindow { + 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 OSD 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 OSD for this window using the sub-DEM + let window_osd = + ObservableSubgraphDecoder::from_dem(&sub_dem, stab_coords, &mut inner_factory)?; + + windows.push(OsdWindow { + 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 WindowedOsdDecoder { + 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-decoder-core/tests/ensemble_integration.rs b/crates/pecos-decoder-core/tests/ensemble_integration.rs new file mode 100644 index 000000000..38073875c --- /dev/null +++ b/crates/pecos-decoder-core/tests/ensemble_integration.rs @@ -0,0 +1,128 @@ +// 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 ensemble decoder. + +use pecos_decoder_core::ObservableDecoder; +use pecos_decoder_core::ensemble::EnsembleDecoder; +use pecos_decoder_core::errors::DecoderError; + +/// A decoder that returns a configurable mask based on syndrome content. +struct ConfigurableDecoder { + /// Observable mask to return when syndrome has any defects. + defect_mask: u64, +} + +impl ObservableDecoder for ConfigurableDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + let has_defects = syndrome.iter().any(|&v| v != 0); + if has_defects { + Ok(self.defect_mask) + } else { + Ok(0) + } + } +} + +/// A decoder that always fails. +struct FailingDecoder; + +impl ObservableDecoder for FailingDecoder { + fn decode_to_observables(&mut self, _syndrome: &[u8]) -> Result { + Err(DecoderError::DecodingFailed("always fails".into())) + } +} + +#[test] +fn test_ensemble_with_three_agreeing_decoders() { + let decoders: Vec> = vec![ + Box::new(ConfigurableDecoder { defect_mask: 0b11 }), + Box::new(ConfigurableDecoder { defect_mask: 0b11 }), + Box::new(ConfigurableDecoder { defect_mask: 0b11 }), + ]; + let mut ens = EnsembleDecoder::new(decoders); + assert_eq!(ens.decode_to_observables(&[1]).unwrap(), 0b11); + assert_eq!(ens.decode_to_observables(&[0]).unwrap(), 0); +} + +#[test] +fn test_ensemble_majority_per_bit() { + // Decoder 1: flips obs 0 and 1 + // Decoder 2: flips obs 0 only + // Decoder 3: flips obs 0 only + // Majority: obs 0 = 3/3 flip, obs 1 = 1/3 flip + let decoders: Vec> = vec![ + Box::new(ConfigurableDecoder { defect_mask: 0b11 }), + Box::new(ConfigurableDecoder { defect_mask: 0b01 }), + Box::new(ConfigurableDecoder { defect_mask: 0b01 }), + ]; + let mut ens = EnsembleDecoder::new(decoders); + assert_eq!(ens.decode_to_observables(&[1]).unwrap(), 0b01); +} + +#[test] +fn test_ensemble_weighted_overrides_majority() { + // 1 decoder votes flip (weight 10), 2 decoders vote no flip (weight 1 each) + let decoders: Vec> = vec![ + Box::new(ConfigurableDecoder { defect_mask: 1 }), + Box::new(ConfigurableDecoder { defect_mask: 0 }), + Box::new(ConfigurableDecoder { defect_mask: 0 }), + ]; + let mut ens = EnsembleDecoder::with_weights(decoders, vec![10.0, 1.0, 1.0]); + // Weight for flip: 10, weight for no flip: 2. Flip wins despite 1/3 majority. + assert_eq!(ens.decode_to_observables(&[1]).unwrap(), 1); +} + +#[test] +fn test_ensemble_propagates_errors() { + let decoders: Vec> = vec![ + Box::new(ConfigurableDecoder { defect_mask: 1 }), + Box::new(FailingDecoder), + ]; + let mut ens = EnsembleDecoder::new(decoders); + let result = ens.decode_to_observables(&[1]); + assert!(result.is_err(), "Should propagate decoder error"); +} + +#[test] +fn test_ensemble_repeated_shots_consistent() { + let decoders: Vec> = vec![ + Box::new(ConfigurableDecoder { defect_mask: 1 }), + Box::new(ConfigurableDecoder { defect_mask: 1 }), + Box::new(ConfigurableDecoder { defect_mask: 0 }), + ]; + let mut ens = EnsembleDecoder::new(decoders); + + // Run the same syndrome 100 times -- must be deterministic. + for _ in 0..100 { + assert_eq!(ens.decode_to_observables(&[1]).unwrap(), 1); + assert_eq!(ens.decode_to_observables(&[0]).unwrap(), 0); + } +} + +#[test] +fn test_ensemble_five_decoders_complex_vote() { + // 5 decoders, 3 bits: + // D0: 0b111, D1: 0b101, D2: 0b100, D3: 0b110, D4: 0b010 + // Bit 0: D0,D1 flip (2/5 < majority) -> 0 + // Bit 1: D0,D3,D4 flip (3/5 majority) -> 1 + // Bit 2: D0,D1,D2,D3 flip (4/5 majority) -> 1 + let decoders: Vec> = vec![ + Box::new(ConfigurableDecoder { defect_mask: 0b111 }), + Box::new(ConfigurableDecoder { defect_mask: 0b101 }), + Box::new(ConfigurableDecoder { defect_mask: 0b100 }), + Box::new(ConfigurableDecoder { defect_mask: 0b110 }), + Box::new(ConfigurableDecoder { defect_mask: 0b010 }), + ]; + let mut ens = EnsembleDecoder::new(decoders); + assert_eq!(ens.decode_to_observables(&[1]).unwrap(), 0b110); +} diff --git a/crates/pecos-decoders/Cargo.toml b/crates/pecos-decoders/Cargo.toml index 5d0731e45..4e219d9dd 100644 --- a/crates/pecos-decoders/Cargo.toml +++ b/crates/pecos-decoders/Cargo.toml @@ -15,20 +15,24 @@ description = "Unified decoder meta-crate for PECOS" pecos-decoder-core.workspace = true pecos-ldpc-decoders = { workspace = true, optional = true } pecos-fusion-blossom = { workspace = true, optional = true } +pecos-mwpf = { workspace = true, optional = true } pecos-pymatching = { workspace = true, optional = true } pecos-tesseract = { workspace = true, optional = true } pecos-chromobius = { workspace = true, optional = true } pecos-relay-bp = { workspace = true, optional = true } +pecos-uf-decoder = { workspace = true, optional = true } [features] default = [] ldpc = ["dep:pecos-ldpc-decoders"] fusion-blossom = ["dep:pecos-fusion-blossom"] +mwpf = ["dep:pecos-mwpf"] pymatching = ["dep:pecos-pymatching"] tesseract = ["dep:pecos-tesseract"] chromobius = ["dep:pecos-chromobius"] relay-bp = ["dep:pecos-relay-bp"] -all = ["ldpc", "fusion-blossom", "pymatching", "tesseract", "chromobius", "relay-bp"] +uf = ["dep:pecos-uf-decoder"] +all = ["ldpc", "fusion-blossom", "mwpf", "pymatching", "tesseract", "chromobius", "relay-bp", "uf"] [lints] workspace = true diff --git a/crates/pecos-decoders/src/lib.rs b/crates/pecos-decoders/src/lib.rs index 53b687f4b..5c9906a45 100644 --- a/crates/pecos-decoders/src/lib.rs +++ b/crates/pecos-decoders/src/lib.rs @@ -11,11 +11,20 @@ //! - `tesseract` - Tesseract search-based decoder (C++ FFI) //! - `chromobius` - Chromobius color code decoder (C++ FFI) //! - `relay-bp` - Relay BP decoder for qLDPC codes (pure Rust) +//! - `uf` - Syndrome-graph Union-Find decoder (pure Rust) //! - `all` - Enable all decoders // Re-export core traits pub use pecos_decoder_core::{ - BatchDecoder, CssDecoder, Decoder, DecoderError, DecodingResultTrait, SoftDecoder, + BatchDecoder, CssDecoder, Decoder, DecoderError, DecodingResultTrait, ObservableDecoder, + SoftDecoder, +}; + +// 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, }; // Re-export LDPC decoders when feature is enabled @@ -51,13 +60,19 @@ pub use pecos_ldpc_decoders::{ UnionFindDecoder, }; +// Re-export MWPF decoder when feature is enabled +#[cfg(feature = "mwpf")] +pub use pecos_mwpf::{MwpfConfig, MwpfDecoder, MwpfDecodingResult, MwpfError, MwpfSolverType}; + // Re-export Fusion Blossom decoder when feature is enabled #[cfg(feature = "fusion-blossom")] pub use pecos_fusion_blossom::{ DecodingOptions as FusionBlossomDecodingOptions, DecodingResult as FusionBlossomDecodingResult, FusionBlossomBuilder, FusionBlossomConfig, FusionBlossomDecoder, FusionBlossomError, - PerfectMatchingInfo, SolverType, StandardCode, SyndromeData, + ParsedCorrelatedDem, PerfectMatchingInfo, SolverType, StandardCode, SyndromeData, }; +#[cfg(feature = "fusion-blossom")] +pub use pecos_fusion_blossom::{PartitionConfig, VertexRange}; // Re-export PyMatching decoder when feature is enabled #[cfg(feature = "pymatching")] @@ -82,6 +97,15 @@ pub use pecos_chromobius::{ DecodingResult as ChromobiusDecodingResult, }; +// Re-export UF decoder when feature is enabled +#[cfg(feature = "uf")] +pub use pecos_uf_decoder::{ + AStarConfig, AStarDecoder, BeamSearchConfig, BeamSearchWindowedDecoder, + BpSchedule as UfBpSchedule, BpUfConfig, BpUfDecoder, CssUfDecoder, OverlappingWindowedDecoder, + QubitEdgeMapping, SandwichWindowedDecoder, StreamingWindowedDecoder, UfDecoder, + UfDecoderConfig, WindowedConfig, WindowedDecoder, +}; + // Re-export Relay BP decoder when feature is enabled #[cfg(feature = "relay-bp")] pub use pecos_relay_bp::{ diff --git a/crates/pecos-engines/src/byte_message/builder.rs b/crates/pecos-engines/src/byte_message/builder.rs index d641511f1..309f275d8 100644 --- a/crates/pecos-engines/src/byte_message/builder.rs +++ b/crates/pecos-engines/src/byte_message/builder.rs @@ -155,6 +155,10 @@ impl ByteMessageBuilder { where I: IntoIterator, { + assert!( + gate_type != GateType::Channel, + "Channel gates carry typed payloads and cannot be encoded in ByteMessage gate commands" + ); let payload_size = size_of::() + num_qubits * size_of::() + (angles.len() + params.len()) * size_of::(); @@ -326,6 +330,12 @@ impl ByteMessageBuilder { /// This function will panic if the number of qubits in the gate exceeds 255, /// as the protocol uses a u8 to represent the qubit count. pub fn add_gate_command(&mut self, gate: &Gate) -> &mut Self { + gate.validate() + .unwrap_or_else(|err| panic!("Invalid gate command: {err}")); + assert!( + !gate.is_channel(), + "Channel gates carry typed payloads and cannot be encoded in ByteMessage gate commands" + ); self.add_gate_parts_from_usizes( gate.gate_type, gate.qubits.len(), @@ -969,6 +979,28 @@ mod tests { assert!(!message.is_empty().unwrap()); } + #[test] + #[should_panic( + expected = "Channel gates carry typed payloads and cannot be encoded in ByteMessage gate commands" + )] + fn test_add_gate_command_rejects_channel_gate() { + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_quantum_operations(); + + let gate = Gate::channel(pecos_core::channel::Depolarizing(0.01, 0)); + builder.add_gate_command(&gate); + } + + #[test] + #[should_panic(expected = "Invalid gate command")] + fn test_add_gate_command_rejects_invalid_gate_payload() { + let mut builder = ByteMessageBuilder::new(); + let _ = builder.for_quantum_operations(); + + let gate = Gate::cx(&[(0, 0)]); + builder.add_gate_command(&gate); + } + #[test] fn test_builder_basic() { // Create a builder diff --git a/crates/pecos-engines/src/byte_message/message.rs b/crates/pecos-engines/src/byte_message/message.rs index 35a9c7163..82b82c566 100644 --- a/crates/pecos-engines/src/byte_message/message.rs +++ b/crates/pecos-engines/src/byte_message/message.rs @@ -288,15 +288,7 @@ impl ByteMessage { } } - match Self::parse_gate_command(payload) { - Ok(cmd) => Some(cmd), - Err(e) => { - if trace_enabled { - trace!("Error parsing gate: {e}"); - } - None - } - } + Some(Self::parse_gate_command(payload)?) } else { None }; @@ -722,6 +714,12 @@ impl ByteMessage { let num_qubits = header.num_qubits as usize; let has_params = header.has_params != 0; let gate_type = GateType::from(header.gate_type); + if gate_type == GateType::Channel { + return Err(PecosError::Input( + "Channel gates carry typed payloads and cannot be encoded in ByteMessage gate commands" + .to_string(), + )); + } if trace_enabled { trace!( @@ -767,7 +765,13 @@ impl ByteMessage { ); } - Ok(Gate::new(gate_type, angles, params, qubits)) + let gate = Gate::new(gate_type, angles, params, qubits); + gate.validate().map_err(|err| { + PecosError::Input(format!( + "Invalid gate command payload for {gate_type:?}: {err}" + )) + })?; + Ok(gate) } // The parse_simple_measurement method has been removed as part of simplifying the protocol. @@ -801,6 +805,67 @@ mod tests { ); } + #[test] + fn test_raw_channel_gate_message_is_rejected() { + use crate::byte_message::protocol::{GateHeader, MessageFlags, MessageType}; + + let header = GateHeader { + gate_type: GateType::Channel as u8, + num_qubits: 1, + has_params: 0, + reserved: 0, + }; + let mut payload = Vec::new(); + payload.extend_from_slice(bytemuck::bytes_of(&header)); + payload.extend_from_slice(&0u32.to_le_bytes()); + + let mut builder = ByteMessage::quantum_operations_builder(); + builder.add_message(MessageType::Gate, &payload, MessageFlags::NONE); + let message = builder.build(); + + let err = message + .quantum_ops() + .expect_err("ByteMessage cannot carry typed channel payloads"); + + assert!( + err.to_string() + .contains("Channel gates carry typed payloads") + ); + } + + #[test] + fn test_raw_invalid_gate_payload_is_rejected_after_parse() { + use crate::byte_message::protocol::{GateHeader, MessageFlags, MessageType}; + + let header = GateHeader { + gate_type: GateType::CX as u8, + num_qubits: 2, + has_params: 0, + reserved: 0, + }; + let mut payload = Vec::new(); + payload.extend_from_slice(bytemuck::bytes_of(&header)); + payload.extend_from_slice(&0u32.to_le_bytes()); + payload.extend_from_slice(&0u32.to_le_bytes()); + + let mut builder = ByteMessage::quantum_operations_builder(); + builder.add_message(MessageType::Gate, &payload, MessageFlags::NONE); + let message = builder.build(); + + let err = message + .quantum_ops() + .expect_err("raw CX payload cannot use the same qubit twice"); + + assert!( + err.to_string().contains("Invalid gate command payload"), + "unexpected error: {err}" + ); + assert!( + err.to_string().contains("requires distinct qubits"), + "unexpected error: {err}" + ); + } + #[test] fn test_message_type() { // Create an empty message diff --git a/crates/pecos-engines/src/noise/biased_depolarizing.rs b/crates/pecos-engines/src/noise/biased_depolarizing.rs index 47d992f12..2bd3cce99 100644 --- a/crates/pecos-engines/src/noise/biased_depolarizing.rs +++ b/crates/pecos-engines/src/noise/biased_depolarizing.rs @@ -73,6 +73,13 @@ pub struct BiasedDepolarizingNoiseModel { impl ProbabilityValidator for BiasedDepolarizingNoiseModel {} impl BiasedDepolarizingNoiseModel { + fn channel_gate_error() -> PecosError { + PecosError::Input( + "ByteMessage noise models cannot process GateType::Channel; channel operations carry typed payloads and must use a channel-aware circuit path" + .to_string(), + ) + } + /// Create a new general noise model with the given probabilities #[must_use] pub fn new(p_prep: f64, p_meas_0: f64, p_meas_1: f64, p1: f64, p2: f64) -> Self { @@ -217,12 +224,16 @@ impl BiasedDepolarizingNoiseModel { trace!("Applying preparation with possible fault"); self.apply_prep_faults(&mut builder, gate); } + GateType::Channel => { + unreachable!("channel gates are rejected before noise is applied") + } GateType::I | GateType::Idle | GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload | GateType::QFree - | GateType::Custom => {} + | GateType::Custom + | GateType::TrackedPauliMeta => {} } } @@ -440,6 +451,9 @@ impl ControlEngine for BiasedDepolarizingNoiseModel { // Parse the input as quantum operations let gates: Vec = input.quantum_ops()?; + if gates.iter().any(Gate::is_channel) { + return Err(Self::channel_gate_error()); + } // Apply noise to the gates let noisy_gates = self.apply_noise_to_gates(&gates); diff --git a/crates/pecos-engines/src/noise/depolarizing.rs b/crates/pecos-engines/src/noise/depolarizing.rs index 9ffd2e171..6f6d3c198 100644 --- a/crates/pecos-engines/src/noise/depolarizing.rs +++ b/crates/pecos-engines/src/noise/depolarizing.rs @@ -81,6 +81,13 @@ pub struct DepolarizingNoiseModel { impl ProbabilityValidator for DepolarizingNoiseModel {} impl DepolarizingNoiseModel { + fn channel_gate_error() -> PecosError { + PecosError::Input( + "ByteMessage noise models cannot process GateType::Channel; channel operations carry typed payloads and must use a channel-aware circuit path" + .to_string(), + ) + } + /// Compute a probability threshold from a f64 probability #[inline] fn compute_threshold(p: f64) -> u64 { @@ -231,12 +238,14 @@ impl DepolarizingNoiseModel { trace!("Applying preparation with possible fault"); Self::apply_prep_faults(rng, p_prep_threshold, builder, gate); } + GateType::Channel => unreachable!("channel gates are rejected before noise is applied"), GateType::I | GateType::Idle | GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload | GateType::QFree - | GateType::Custom => { + | GateType::Custom + | GateType::TrackedPauliMeta => { // Just pass through with no added noise. } } @@ -563,6 +572,15 @@ impl ControlEngine for DepolarizingNoiseModel { // For quantum operations, apply gate noise trace!("DepolarizingNoise::start - applying noise to quantum operations"); + self.scratch_gates.clear(); + input + .quantum_ops_into(&mut self.scratch_gates) + .map_err(|e| PecosError::Input(format!("Failed to parse quantum operations: {e}")))?; + + if self.scratch_gates.iter().any(Gate::is_channel) { + return Err(Self::channel_gate_error()); + } + if self.p_prep_threshold == 0 && self.p_meas_threshold == 0 && self.p1_threshold == 0 @@ -571,11 +589,6 @@ impl ControlEngine for DepolarizingNoiseModel { return Ok(EngineStage::NeedsProcessing(input)); } - self.scratch_gates.clear(); - input - .quantum_ops_into(&mut self.scratch_gates) - .map_err(|e| PecosError::Input(format!("Failed to parse quantum operations: {e}")))?; - self.scratch_builder.reset(); let _ = self.scratch_builder.for_quantum_operations(); diff --git a/crates/pecos-engines/src/noise/general.rs b/crates/pecos-engines/src/noise/general.rs index 28bf37574..d64b4dde2 100644 --- a/crates/pecos-engines/src/noise/general.rs +++ b/crates/pecos-engines/src/noise/general.rs @@ -447,6 +447,11 @@ impl RngManageable for GeneralNoiseModel { impl ProbabilityValidator for GeneralNoiseModel {} impl GeneralNoiseModel { + fn channel_gate_error() -> String { + "ByteMessage noise models cannot process GateType::Channel; channel operations carry typed payloads and must use a channel-aware circuit path" + .to_string() + } + /// Create a new noise model with the specified error parameters /// /// Creates a `GeneralNoiseModel` with the specified error probabilities while using default values @@ -517,6 +522,9 @@ impl GeneralNoiseModel { let gates = input .quantum_ops() .expect("Failed to parse input as quantum operations"); + if gates.iter().any(Gate::is_channel) { + return Err(Self::channel_gate_error()); + } for gate in gates { // Track which qubits are being measured for leakage handling @@ -1559,6 +1567,8 @@ mod tests { angles: vec![].into(), qubits: vec![QubitId(qubit)].into(), params: vec![].into(), + meas_ids: vec![].into(), + channel: None, }); } let measurement_request = request_builder.build(); @@ -1640,6 +1650,8 @@ mod tests { angles: vec![].into(), qubits: vec![QubitId(0)].into(), params: vec![].into(), + meas_ids: vec![].into(), + channel: None, }; // Create a builder and apply noise @@ -1829,6 +1841,8 @@ mod tests { angles: vec![].into(), qubits: vec![QubitId(0)].into(), params: vec![].into(), + meas_ids: vec![].into(), + channel: None, }; noise.apply_prep_faults(&prep_gate, &mut builder); @@ -2746,6 +2760,8 @@ mod tests { angles: vec![].into(), qubits: vec![QubitId(0)].into(), params: vec![1.0].into(), // 1 second duration + meas_ids: vec![].into(), + channel: None, }; // Apply idle faults - should use coherent dephasing (RZ gates) @@ -2769,6 +2785,8 @@ mod tests { angles: vec![].into(), qubits: vec![QubitId(0), QubitId(1), QubitId(2)].into(), // 3 qubits params: vec![1.0].into(), // 1 second duration + meas_ids: vec![].into(), + channel: None, }; model.apply_idle_faults( @@ -2905,6 +2923,8 @@ mod tests { angles: vec![Angle64::from_radians(0.1)].into(), qubits: vec![QubitId(0)].into(), params: vec![].into(), + meas_ids: vec![].into(), + channel: None, }; // Create an X gate (not noiseless - should have noise applied) @@ -2913,6 +2933,8 @@ mod tests { angles: vec![].into(), qubits: vec![QubitId(0)].into(), params: vec![].into(), + meas_ids: vec![].into(), + channel: None, }; // Make sure RZ is recognized as noiseless diff --git a/crates/pecos-engines/src/quantum.rs b/crates/pecos-engines/src/quantum.rs index 7f10035d3..9039080cb 100644 --- a/crates/pecos-engines/src/quantum.rs +++ b/crates/pecos-engines/src/quantum.rs @@ -4,6 +4,7 @@ use crate::byte_message::GateType; use dyn_clone::DynClone; use log::debug; use pecos_core::Angle64; +use pecos_core::ChannelExpr; use pecos_core::QubitId; use pecos_core::RngManageable; use pecos_core::errors::PecosError; @@ -54,6 +55,26 @@ fn flat_to_pairs(qubits: &[QubitId]) -> Vec<(QubitId, QubitId)> { pairs } +trait ChannelDispatch { + fn apply_channel_expr(&mut self, channel: &ChannelExpr) -> Result<(), PecosError>; +} + +impl ChannelDispatch for StabVec { + fn apply_channel_expr(&mut self, _channel: &ChannelExpr) -> Result<(), PecosError> { + Err(quantum_error( + "Channel gate requires a channel-aware simulator path", + )) + } +} + +impl ChannelDispatch for DensityMatrix { + fn apply_channel_expr(&mut self, channel: &ChannelExpr) -> Result<(), PecosError> { + DensityMatrix::apply_channel_expr(self, channel) + .map(|_| ()) + .map_err(|err| quantum_error(format!("invalid channel gate: {err}"))) + } +} + /// Process a `ByteMessage` against any Clifford-capable simulator. /// /// Shared gate dispatch for `SparseStabEngine`, `StabilizerEngine`, etc. @@ -169,9 +190,9 @@ fn process_clifford_message( +fn process_general_message< + S: CliffordGateable + ArbitraryRotationGateable + QuantumSimulator + ChannelDispatch, +>( sim: &mut S, message: &ByteMessage, ) -> Result { @@ -519,15 +542,15 @@ fn process_general_message { - let meas_results = sim.mz(&cmd.qubits); - for meas_result in meas_results { - measurements.push(usize::from(meas_result.outcome)); + let meas_ids = sim.mz(&cmd.qubits); + for meas_id in meas_ids { + measurements.push(usize::from(meas_id.outcome)); } } @@ -535,6 +558,12 @@ fn process_general_message { sim.pz(&cmd.qubits); } + GateType::Channel => { + let channel = cmd + .channel_expr() + .ok_or_else(|| quantum_error("Channel gate is missing its channel payload"))?; + sim.apply_channel_expr(channel)?; + } // No-ops GateType::I @@ -542,7 +571,8 @@ fn process_general_message {} + | GateType::Custom + | GateType::TrackedPauliMeta => {} } cmd_idx += 1; } @@ -1084,21 +1114,27 @@ where "Processing batched measurement on {} qubits", mz_qubits.len() ); - let meas_results = self.simulator.mz(&mz_qubits); - for meas_result in meas_results { - measurements.push(usize::from(meas_result.outcome)); + let meas_ids = self.simulator.mz(&mz_qubits); + for meas_id in meas_ids { + measurements.push(usize::from(meas_id.outcome)); } } GateType::PZ => { debug!("Processing Prep gate on qubits {:?}", cmd.qubits); self.simulator.pz(&cmd.qubits); } + GateType::Channel => { + return Err(quantum_error( + "Channel gate requires a channel-aware simulator path", + )); + } GateType::I | GateType::Idle | GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload | GateType::QFree - | GateType::Custom => { + | GateType::Custom + | GateType::TrackedPauliMeta => { // Just let the system naturally evolve for the specified duration // No active operation needed in the simulator // QFree is a no-op for state vector simulation (qubit tracking is handled elsewhere) @@ -1142,9 +1178,9 @@ where GateType::MeasureFree => { // Measure and deallocate - measure first, then the qubit is implicitly freed debug!("Processing MeasureFree gate on qubits {:?}", cmd.qubits); - let meas_results = self.simulator.mz(&cmd.qubits); - for meas_result in meas_results { - measurements.push(usize::from(meas_result.outcome)); + let meas_ids = self.simulator.mz(&cmd.qubits); + for meas_id in meas_ids { + measurements.push(usize::from(meas_id.outcome)); } } GateType::U => { @@ -1629,9 +1665,9 @@ impl Engine for CoinTossEngine { GateType::MZ | GateType::MeasureLeaked | GateType::MeasureFree => { for q in &cmd.qubits { debug!("CoinToss: Processing measurement on qubit {q:?}"); - let meas_results = self.simulator.mz(&[*q]); - for meas_result in &meas_results { - let outcome = u32::from(meas_result.outcome); + let meas_ids = self.simulator.mz(&[*q]); + for meas_id in &meas_ids { + let outcome = u32::from(meas_id.outcome); measurements.push(outcome); } } diff --git a/crates/pecos-foreign/src/conformance.rs b/crates/pecos-foreign/src/conformance.rs index 103d80e45..e2387f279 100644 --- a/crates/pecos-foreign/src/conformance.rs +++ b/crates/pecos-foreign/src/conformance.rs @@ -15,9 +15,14 @@ //! //! # Usage from Rust //! -//! ```rust,ignore -//! let results = run_conformance_tests(&mut foreign_sim); -//! assert!(results.all_passed()); +//! ```rust,no_run +//! use pecos_foreign::ForeignSimulator; +//! use pecos_foreign::conformance::run_conformance_tests; +//! +//! fn check_foreign_simulator(mut foreign_sim: ForeignSimulator) { +//! let results = run_conformance_tests(&mut foreign_sim); +//! assert!(results.all_passed()); +//! } //! ``` use crate::simulator::{ForeignSimulator, ForeignSimulatorVTable}; diff --git a/crates/pecos-foreign/src/gate_support.rs b/crates/pecos-foreign/src/gate_support.rs index 31e232b3a..c42416c6b 100644 --- a/crates/pecos-foreign/src/gate_support.rs +++ b/crates/pecos-foreign/src/gate_support.rs @@ -11,9 +11,14 @@ //! //! # Example //! -//! ```rust,ignore -//! let sim = ForeignSimulator::new(handle, vtable); -//! let runner = configure_runner_for_foreign(&sim); +//! ```rust,no_run +//! use pecos_foreign::ForeignSimulator; +//! use pecos_foreign::gate_support::configure_runner_for_foreign; +//! +//! fn configure_foreign_runner(sim: &ForeignSimulator) { +//! let runner = configure_runner_for_foreign(sim); +//! let _ = runner; +//! } //! // runner will decompose unsupported gates into {SZ, H, CX, MZ, RX?, RZ?, RZZ?} //! ``` diff --git a/crates/pecos-fusion-blossom/Cargo.toml b/crates/pecos-fusion-blossom/Cargo.toml index 3c4d73b99..9ebfc8370 100644 --- a/crates/pecos-fusion-blossom/Cargo.toml +++ b/crates/pecos-fusion-blossom/Cargo.toml @@ -16,6 +16,7 @@ pecos-decoder-core.workspace = true ndarray.workspace = true thiserror.workspace = true fusion-blossom.workspace = true +serde_json.workspace = true [lib] name = "pecos_fusion_blossom" diff --git a/crates/pecos-fusion-blossom/examples/fusion_blossom_usage.rs b/crates/pecos-fusion-blossom/examples/fusion_blossom_usage.rs index 1c049ce75..ec3fe5128 100644 --- a/crates/pecos-fusion-blossom/examples/fusion_blossom_usage.rs +++ b/crates/pecos-fusion-blossom/examples/fusion_blossom_usage.rs @@ -85,7 +85,7 @@ fn main() -> Result<(), Box> { let result = decoder.decode(&syndrome.view())?; println!("Decoded observables: {:?}", result.observable); println!("Total weight: {:.2}", result.weight); - println!("Observable errors detected: "); + println!("Logical observable errors detected: "); for (i, &obs) in result.observable.iter().enumerate() { if obs != 0 { println!(" - Observable {i} flipped"); diff --git a/crates/pecos-fusion-blossom/src/core_traits.rs b/crates/pecos-fusion-blossom/src/core_traits.rs index cded1dada..ee44d32b4 100644 --- a/crates/pecos-fusion-blossom/src/core_traits.rs +++ b/crates/pecos-fusion-blossom/src/core_traits.rs @@ -33,6 +33,165 @@ impl Decoder for FusionBlossomDecoder { } } +/// Implement `ObservableDecoder` for `FusionBlossomDecoder`. +/// +/// Uses the fast decode path with pre-computed observable bitmasks. +impl pecos_decoder_core::ObservableDecoder for FusionBlossomDecoder { + fn decode_to_observables( + &mut self, + syndrome: &[u8], + ) -> Result { + self.decode_to_obs_mask(syndrome) + .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string())) + } +} + +/// Implement `ObservableErasureDecoder` for `FusionBlossomDecoder`. +/// +/// Neutral atom erasure support: erased edges are marked in the syndrome data, +/// causing the solver to treat them as known errors (zero weight). +impl pecos_decoder_core::erasure::ObservableErasureDecoder for FusionBlossomDecoder { + fn decode_with_erasures( + &mut self, + syndrome: &[u8], + erasure_edges: &[usize], + ) -> Result { + if erasure_edges.is_empty() { + return self + .decode_to_obs_mask(syndrome) + .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string())); + } + + let defects: Vec = syndrome + .iter() + .enumerate() + .filter_map(|(i, &v)| if v != 0 { Some(i) } else { None }) + .collect(); + + if defects.is_empty() && erasure_edges.is_empty() { + return Ok(0); + } + + let syndrome_data = SyndromeData::with_erasures(defects, erasure_edges.to_vec()); + + let result = self + .decode_with_options(syndrome_data, DecodingOptions::default()) + .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; + + let edge_indices: Vec = result.matched_edges.clone(); + Ok(self.obs_mask_from_edges(&edge_indices)) + } +} + +/// Implement `MatchingDecoder` for `FusionBlossomDecoder`. +/// +/// Enables the two-pass correlated DGR decode by exposing which edges +/// were matched and accepting per-shot dynamic weight adjustments. +impl pecos_decoder_core::correlated_decoder::MatchingDecoder for FusionBlossomDecoder { + fn decode_with_matching( + &mut self, + syndrome: &[u8], + ) -> Result<(u64, Vec), pecos_decoder_core::DecoderError> { + // Extract defects + let defects: Vec = syndrome + .iter() + .enumerate() + .filter_map(|(i, &v)| if v != 0 { Some(i) } else { None }) + .collect(); + + if defects.is_empty() { + return Ok((0, Vec::new())); + } + + let syndrome_data = SyndromeData::from_defects(defects); + let result = self + .decode_with_options(syndrome_data, DecodingOptions::default()) + .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; + + let edge_indices: Vec = result.matched_edges.clone(); + let mask = self.obs_mask_from_edges(&edge_indices); + + Ok((mask, edge_indices)) + } + + fn decode_with_weights( + &mut self, + syndrome: &[u8], + weights: &[f64], + ) -> Result<(u64, Vec), pecos_decoder_core::DecoderError> { + let defects: Vec = syndrome + .iter() + .enumerate() + .filter_map(|(i, &v)| if v != 0 { Some(i) } else { None }) + .collect(); + + if defects.is_empty() { + return Ok((0, Vec::new())); + } + + // Convert f64 weights to Fusion Blossom's integer dynamic weights. + // FB uses integer weights with 1000x scaling, must be even. + // Only include edges whose weight differs from the original to avoid + // disrupting the solver with redundant overrides. + #[allow(clippy::cast_possible_truncation)] + let dynamic_weights: Vec<(usize, i32)> = weights + .iter() + .enumerate() + .filter_map(|(i, &w)| { + // Clamp to positive (FB doesn't handle negative weights) + let clamped = w.max(0.01); + let int_w = ((clamped * 1000.0) as i32 / 2) * 2; + // Only include if it's a real weight (not a huge default) + if int_w > 0 && int_w < 40_000 { + Some((i, int_w)) + } else { + None + } + }) + .collect(); + + let mut syndrome_data = SyndromeData::from_defects(defects); + if !dynamic_weights.is_empty() { + syndrome_data.dynamic_weights = Some(dynamic_weights); + } + + let result = self + .decode_with_options(syndrome_data, DecodingOptions::default()) + .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; + + let edge_indices: Vec = result.matched_edges.clone(); + let mask = self.obs_mask_from_edges(&edge_indices); + + Ok((mask, edge_indices)) + } + + fn num_edges(&self) -> usize { + self.num_edges() + } +} + +impl pecos_decoder_core::correlated_decoder::EdgeTrackingDecoder for FusionBlossomDecoder { + fn edge_node1(&self, edge_idx: usize) -> u32 { + self.edge_endpoints(edge_idx).map_or(0, |(n1, _, _)| n1) + } + + fn edge_node2(&self, edge_idx: usize) -> u32 { + self.edge_endpoints(edge_idx).map_or(0, |(_, n2, _)| n2) + } + + fn edge_weight(&self, edge_idx: usize) -> f64 { + self.edge_endpoints(edge_idx).map_or(0.0, |(_, _, w)| w) + } + + fn edge_obs_mask(&self, edge_idx: usize) -> u64 { + self.edge_obs_mask(edge_idx) + } + + fn num_detectors(&self) -> usize { + self.num_nodes() + } +} + /// Implement `DecodingResultTrait` for `FusionBlossom`'s `DecodingResult` impl DecodingResultTrait for DecodingResult { fn is_successful(&self) -> bool { @@ -40,6 +199,10 @@ impl DecodingResultTrait for DecodingResult { true } + fn correction(&self) -> &[u8] { + &self.observable + } + fn cost(&self) -> Option { Some(self.weight) } diff --git a/crates/pecos-fusion-blossom/src/decoder.rs b/crates/pecos-fusion-blossom/src/decoder.rs index abe3bc0c7..ad05cd961 100644 --- a/crates/pecos-fusion-blossom/src/decoder.rs +++ b/crates/pecos-fusion-blossom/src/decoder.rs @@ -6,13 +6,19 @@ use fusion_blossom::{ CircuitLevelPlanarCode, CodeCapacityPlanarCode, CodeCapacityRotatedCode, ExampleCode, PhenomenologicalPlanarCode, PhenomenologicalRotatedCode, }, - mwpm_solver::{LegacySolverSerial, PrimalDualSolver, SolverSerial}, - util::{EdgeIndex, SolverInitializer, SyndromePattern, VertexIndex, Weight}, + mwpm_solver::{LegacySolverSerial, SolverDualParallel, SolverSerial}, + util::{EdgeIndex, PartitionConfig, SolverInitializer, SyndromePattern, VertexIndex, Weight}, }; use ndarray::{Array2, ArrayView1}; -use std::collections::HashMap; +use std::collections::{BTreeMap, HashMap}; use std::fmt; +struct ParsedEdgeInfo { + obs: Vec, + prob: f64, + best_prob: f64, +} + /// Solver type selection #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)] pub enum SolverType { @@ -21,6 +27,8 @@ pub enum SolverType { /// Serial solver (improved performance) #[default] Serial, + /// Parallel solver (intra-shot parallelism via partitioning) + Parallel, } /// Configuration for Fusion Blossom decoder @@ -179,6 +187,18 @@ pub enum StandardCode { enum Solver { Legacy(LegacySolverSerial), Serial(SolverSerial), + Parallel(SolverDualParallel), +} + +/// Pre-parsed correlated DEM for fast repeated FB construction. +#[derive(Clone)] +pub struct ParsedCorrelatedDem { + /// Number of detector nodes. + pub num_detectors: usize, + /// Number of observables. + pub num_observables: usize, + /// Per mechanism: (`detector_indices`, `observable_indices`, `original_weight`). + pub mechanisms: Vec<(Vec, Vec, f64)>, } /// Fusion Blossom decoder @@ -186,6 +206,8 @@ pub struct FusionBlossomDecoder { config: FusionBlossomConfig, /// Map from edge index to observable mask edge_observables: HashMap>, + /// Pre-computed observable bitmask per edge (for fast decode path) + edge_obs_masks: Vec, /// Number of nodes (detectors) num_nodes: usize, /// Virtual boundary node (if used) @@ -193,11 +215,17 @@ pub struct FusionBlossomDecoder { /// Edges to be added to the initializer weighted_edges: Vec<(VertexIndex, VertexIndex, Weight)>, /// Virtual vertices - virtual_vertices: Vec, + pub virtual_vertices: Vec, /// Cached solver instance for reuse solver: Option, /// Cached initializer initializer: Option, + /// Partition config for parallel solver (None for serial) + partition_config: Option, + /// Reusable buffer for padded syndrome (avoids per-shot allocation) + _syndrome_buf: Vec, + /// Reusable buffer for defect vertices + defect_buf: Vec, } impl FusionBlossomDecoder { @@ -235,15 +263,295 @@ impl FusionBlossomDecoder { Ok(Self { config, edge_observables: HashMap::new(), + edge_obs_masks: Vec::new(), + partition_config: None, num_nodes, boundary_node: None, weighted_edges: Vec::new(), virtual_vertices: Vec::new(), solver: None, initializer: None, + _syndrome_buf: vec![0u8; num_nodes + 1], // +1 for possible boundary node + defect_buf: Vec::new(), + }) + } + + /// Create decoder from a `DemMatchingGraph`. + /// + /// # Errors + /// + /// Returns error if the graph is empty or construction fails. + pub fn from_matching_graph(graph: &pecos_decoder_core::dem::DemMatchingGraph) -> Result { + let config = FusionBlossomConfig { + num_nodes: Some(graph.num_detectors), + num_observables: graph.num_observables, + ..Default::default() + }; + let mut decoder = Self::new(config)?; + for edge in &graph.edges { + let obs: Vec = edge.observables.iter().map(|&o| o as usize).collect(); + match edge.node2 { + Some(n2) => { + decoder.add_edge(edge.node1 as usize, n2 as usize, &obs, Some(edge.weight))?; + } + None => { + decoder.add_boundary_edge(edge.node1 as usize, &obs, Some(edge.weight))?; + } + } + } + decoder.build_obs_masks(); + Ok(decoder) + } + + /// Create decoder from a DEM string. + /// + /// # Errors + /// + /// Returns error if the DEM is malformed. + pub fn from_dem(dem: &str) -> Result { + let graph = pecos_decoder_core::dem::DemMatchingGraph::from_dem_str(dem) + .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; + Self::from_matching_graph(&graph) + } + + /// Parse a DEM string into a reusable structure for correlated FB construction. + /// + /// # Errors + /// + /// Returns error if the DEM is malformed. + pub fn parse_correlated_dem(dem: &str) -> Result { + use pecos_decoder_core::dem::DemCheckMatrix; + + let dcm = DemCheckMatrix::from_dem_str(dem) + .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; + + let mut mechanisms = Vec::new(); + for m in 0..dcm.num_mechanisms { + let p = dcm.error_priors[m]; + if p <= 0.0 { + continue; + } + + let detectors: Vec = (0..dcm.num_detectors) + .filter(|&d| dcm.check_matrix[[d, m]] != 0) + .collect(); + + let obs: Vec = (0..dcm.num_observables) + .filter(|&o| dcm.observable_matrix[[o, m]] != 0) + .collect(); + + let weight = if p < 1.0 { ((1.0 - p) / p).ln() } else { 0.0 }; + + // Only graphlike (1-2 detector) mechanisms. + if detectors.len() <= 2 { + mechanisms.push((detectors, obs, weight)); + } + } + + Ok(ParsedCorrelatedDem { + num_detectors: dcm.num_detectors, + num_observables: dcm.num_observables, + mechanisms, }) } + /// Build a correlated FB decoder from pre-parsed data with optional weight perturbation. + /// + /// `weight_factors[i]` multiplies the i-th mechanism's weight. Pass `None` for no perturbation. + /// Duplicate edges (same endpoints) are merged by keeping the lowest weight + /// (highest probability) mechanism's observable — matching PM's INDEPENDENT strategy. + /// + /// # Errors + /// + /// Returns error if construction fails. + pub fn from_parsed_correlated( + parsed: &ParsedCorrelatedDem, + weight_factors: Option<&[f64]>, + ) -> Result { + let config = FusionBlossomConfig { + num_nodes: Some(parsed.num_detectors), + num_observables: parsed.num_observables, + ..Default::default() + }; + let mut decoder = Self::new(config)?; + + // Deduplicate edges: merge by independent-union probability, + // first-observable-wins (stable under perturbation). + // Key: (min_node, max_node, is_boundary). Value: (obs, prob, best_prob). + let mut edge_map: BTreeMap<(usize, usize, bool), ParsedEdgeInfo> = BTreeMap::new(); + + for (i, (detectors, obs, base_weight)) in parsed.mechanisms.iter().enumerate() { + let weight = if let Some(factors) = weight_factors { + if i < factors.len() { + (base_weight * factors[i]).max(0.01) + } else { + *base_weight + } + } else { + *base_weight + }; + // Convert weight to probability for merging. + let prob = 1.0 / (1.0 + weight.exp()); + + let key = match detectors.len() { + 1 => (detectors[0], usize::MAX, true), + 2 => { + let (a, b) = if detectors[0] < detectors[1] { + (detectors[0], detectors[1]) + } else { + (detectors[1], detectors[0]) + }; + (a, b, false) + } + _ => continue, + }; + + let entry = edge_map.entry(key).or_insert_with(|| ParsedEdgeInfo { + obs: obs.clone(), + prob, + best_prob: prob, + }); + // Independent union: P(A or B) = P(A) + P(B) - P(A)*P(B) + entry.prob = entry.prob + prob - entry.prob * prob; + if prob > entry.best_prob { + entry.obs.clone_from(obs); + entry.best_prob = prob; + } + } + + for ((n1, n2, is_boundary), info) in &edge_map { + // Convert combined probability back to weight. + let p = info.prob.clamp(1e-15, 1.0 - 1e-15); + let weight = ((1.0 - p) / p).ln(); + if *is_boundary { + decoder.add_boundary_edge(*n1, &info.obs, Some(weight))?; + } else { + decoder.add_edge(*n1, *n2, &info.obs, Some(weight))?; + } + } + + decoder.build_obs_masks(); + Ok(decoder) + } + + /// Create decoder from a pre-parsed `DemCheckMatrix` preserving all mechanisms. + /// + /// Like `from_dem_correlated` but skips DEM string parsing. + /// Optionally applies multiplicative weight perturbation via `weight_factors`. + /// + /// # Errors + /// + /// Returns error if construction fails. + pub fn from_check_matrix_correlated( + dcm: &pecos_decoder_core::dem::DemCheckMatrix, + weight_factors: Option<&[f64]>, + ) -> Result { + // Use Legacy solver which tolerates duplicate edges (no assertion). + let config = FusionBlossomConfig { + num_nodes: Some(dcm.num_detectors), + num_observables: dcm.num_observables, + solver_type: SolverType::Legacy, + ..Default::default() + }; + let mut decoder = Self::new(config)?; + + for m in 0..dcm.num_mechanisms { + let p = dcm.error_priors[m]; + if p <= 0.0 { + continue; + } + + let detectors: Vec = (0..dcm.num_detectors) + .filter(|&d| dcm.check_matrix[[d, m]] != 0) + .collect(); + + let obs: Vec = (0..dcm.num_observables) + .filter(|&o| dcm.observable_matrix[[o, m]] != 0) + .collect(); + + let mut weight = if p < 1.0 { ((1.0 - p) / p).ln() } else { 0.0 }; + + if let Some(factors) = weight_factors + && m < factors.len() + { + weight *= factors[m]; + weight = weight.max(0.01); + } + + match detectors.len() { + 1 => { + decoder.add_boundary_edge(detectors[0], &obs, Some(weight))?; + } + 2 => { + decoder.add_edge(detectors[0], detectors[1], &obs, Some(weight))?; + } + _ => {} + } + } + + decoder.build_obs_masks(); + Ok(decoder) + } + + /// Create decoder from a DEM string preserving all mechanisms. + /// + /// Unlike `from_dem` which uses `DemMatchingGraph` (merges duplicate edges), + /// this uses `DemCheckMatrix` to keep every mechanism as a separate edge. + /// This preserves X-Z correlations from Y errors, similar to `PyMatching`'s + /// `enable_correlations` mode. + /// + /// Only 2-detector mechanisms are included (3+ detector hyperedges are + /// skipped since FB is a matching decoder). + /// + /// # Errors + /// + /// Returns error if the DEM is malformed. + pub fn from_dem_correlated(dem: &str) -> Result { + use pecos_decoder_core::dem::DemCheckMatrix; + + let dcm = DemCheckMatrix::from_dem_str(dem) + .map_err(|e| FusionBlossomError::Configuration(e.to_string()))?; + + let config = FusionBlossomConfig { + num_nodes: Some(dcm.num_detectors), + num_observables: dcm.num_observables, + ..Default::default() + }; + let mut decoder = Self::new(config)?; + + for m in 0..dcm.num_mechanisms { + let p = dcm.error_priors[m]; + if p <= 0.0 { + continue; + } + + let detectors: Vec = (0..dcm.num_detectors) + .filter(|&d| dcm.check_matrix[[d, m]] != 0) + .collect(); + + // Only handle 2-detector (graphlike) mechanisms. + // 1-detector = boundary, 3+ = hyperedge (skip). + let obs: Vec = (0..dcm.num_observables) + .filter(|&o| dcm.observable_matrix[[o, m]] != 0) + .collect(); + + let weight = if p < 1.0 { ((1.0 - p) / p).ln() } else { 0.0 }; + + match detectors.len() { + 1 => { + decoder.add_boundary_edge(detectors[0], &obs, Some(weight))?; + } + 2 => { + decoder.add_edge(detectors[0], detectors[1], &obs, Some(weight))?; + } + _ => {} // Skip hyperedges + } + } + + decoder.build_obs_masks(); + Ok(decoder) + } + /// Create decoder from a standard QEC code /// /// # Errors @@ -331,12 +639,16 @@ impl FusionBlossomDecoder { ..config }, edge_observables, + edge_obs_masks: Vec::new(), num_nodes, boundary_node: None, weighted_edges: initializer.weighted_edges.clone(), virtual_vertices: initializer.virtual_vertices.clone(), solver: None, initializer: Some(initializer), + partition_config: None, + _syndrome_buf: vec![0u8; num_nodes + 1], + defect_buf: Vec::new(), }; // Identify boundary nodes from virtual vertices @@ -508,9 +820,21 @@ impl FusionBlossomDecoder { Ok(decoder) } - /// Clear the solver state for reuse + /// Set partition config for parallel solving. + /// + /// The partition config defines how the matching graph is split across + /// threads for intra-shot parallelism. + pub fn set_partition_config(&mut self, config: PartitionConfig) { + self.partition_config = Some(config); + self.config.solver_type = SolverType::Parallel; + self.solver = None; // force solver recreation + } + + /// Clear the solver state for reuse between decodes. + /// + /// The Serial solver is cleared inline after each solve. This method + /// exists for external callers and the Legacy solver fallback. pub fn clear(&mut self) { - // For Fusion Blossom, we need to recreate the solver to clear state self.solver = None; } @@ -544,6 +868,18 @@ impl FusionBlossomDecoder { let solver = match self.config.solver_type { SolverType::Legacy => Solver::Legacy(LegacySolverSerial::new(&initializer)), SolverType::Serial => Solver::Serial(SolverSerial::new(&initializer)), + SolverType::Parallel => { + let partition_info = self + .partition_config + .as_ref() + .expect("partition_config must be set for Parallel solver") + .info(); + Solver::Parallel(SolverDualParallel::new( + &initializer, + &partition_info, + serde_json::json!({}), + )) + } }; self.solver = Some(solver); @@ -561,7 +897,7 @@ impl FusionBlossomDecoder { pub fn decode_with_options( &mut self, syndrome_data: SyndromeData, - options: DecodingOptions, + _options: DecodingOptions, ) -> Result { // Convert defects to VertexIndex let defect_vertices: Vec = syndrome_data @@ -602,34 +938,27 @@ impl FusionBlossomDecoder { SyndromePattern::new_vertices(defect_vertices) }; - // Get or create solver + // Get or create solver, solve, extract results, then clear for next use. let solver = self.get_or_create_solver(); - // Solve and get perfect matching if requested let (matched_edges, perfect_matching_info) = match solver { Solver::Legacy(s) => { let edges = s.solve_subgraph(&syndrome_pattern); - let pm_info = if options.include_perfect_matching { - // Legacy solver doesn't have easy access to perfect matching - None - } else { - None - }; - (edges, pm_info) + (edges, None) } Solver::Serial(s) => { + use fusion_blossom::mwpm_solver::PrimalDualSolver; s.solve(&syndrome_pattern); let edges = s.subgraph(); - - let pm_info = if options.include_perfect_matching { - // For Serial solver, we can't easily get perfect matching details - // without accessing internal structures - None - } else { - None - }; - - (edges, pm_info) + s.clear(); + (edges, None) + } + Solver::Parallel(s) => { + use fusion_blossom::mwpm_solver::PrimalDualSolver; + s.solve(&syndrome_pattern); + let edges = s.subgraph(); + s.clear(); + (edges, None) } }; @@ -712,7 +1041,13 @@ impl FusionBlossomDecoder { self.initializer = None; } - /// Get number of nodes + /// Get the boundary node index, if one exists. + #[must_use] + pub fn boundary_node(&self) -> Option { + self.boundary_node + } + + /// Get number of nodes. #[must_use] pub fn num_nodes(&self) -> usize { self.num_nodes @@ -723,4 +1058,103 @@ impl FusionBlossomDecoder { pub fn num_edges(&self) -> usize { self.weighted_edges.len() } + + /// Get node endpoints and weight of an edge by index. + #[must_use] + pub fn edge_endpoints(&self, edge_idx: usize) -> Option<(u32, u32, f64)> { + self.weighted_edges + .get(edge_idx) + .map(|&(n1, n2, w)| (n1 as u32, n2 as u32, (w as f64) / 1000.0)) + } + + /// Get per-edge observable bitmask. + #[must_use] + pub fn edge_obs_mask(&self, edge_idx: usize) -> u64 { + self.edge_obs_masks.get(edge_idx).copied().unwrap_or(0) + } + + /// Compute observable mask from a set of matched edge indices. + /// Uses pre-computed bitmasks (builds them on first call). + pub fn obs_mask_from_edges(&mut self, matched_edges: &[usize]) -> u64 { + if self.edge_obs_masks.is_empty() && !self.edge_observables.is_empty() { + self.build_obs_masks(); + } + let mut mask = 0u64; + for &edge_idx in matched_edges { + if let Some(&m) = self.edge_obs_masks.get(edge_idx) { + mask ^= m; + } + } + mask + } + + /// Build pre-computed observable bitmasks for fast decode path. + /// Call once after all edges are added. + pub fn build_obs_masks(&mut self) { + let n = self.weighted_edges.len(); + self.edge_obs_masks = vec![0u64; n]; + for (&edge_idx, obs_indices) in &self.edge_observables { + if edge_idx < n { + let mut mask = 0u64; + for &obs_idx in obs_indices { + mask |= 1 << obs_idx; + } + self.edge_obs_masks[edge_idx] = mask; + } + } + } + + /// Fast decode: syndrome bytes -> observable bitmask. + /// Uses reusable buffers and pre-computed observable masks. + /// Handles padding for boundary node internally. + /// + /// # Errors + /// + /// Returns a `FusionBlossomError` if the solver cannot decode the supplied + /// syndrome. + pub fn decode_to_obs_mask(&mut self, syndrome: &[u8]) -> Result { + // Build obs masks on first call + if self.edge_obs_masks.is_empty() && !self.edge_observables.is_empty() { + self.build_obs_masks(); + } + + // Fill defect buffer from syndrome (pad to num_nodes for boundary) + self.defect_buf.clear(); + for (i, &v) in syndrome.iter().enumerate() { + if v != 0 { + self.defect_buf.push(i as VertexIndex); + } + } + // No defects in padding region (boundary node always 0) + + if self.defect_buf.is_empty() { + return Ok(0); + } + + let syndrome_pattern = SyndromePattern::new_vertices(self.defect_buf.clone()); + let solver = self.get_or_create_solver(); + + let matched_edges = match solver { + Solver::Legacy(s) => s.solve_subgraph(&syndrome_pattern), + Solver::Serial(s) => { + use fusion_blossom::mwpm_solver::PrimalDualSolver; + s.solve(&syndrome_pattern); + let edges = s.subgraph(); + s.clear(); + edges + } + Solver::Parallel(s) => { + use fusion_blossom::mwpm_solver::PrimalDualSolver; + s.solve(&syndrome_pattern); + let edges = s.subgraph(); + s.clear(); + edges + } + }; + + // Compute observable mask using pre-computed bitmasks + let edge_indices: Vec = matched_edges.clone(); + let mask = self.obs_mask_from_edges(&edge_indices); + Ok(mask) + } } diff --git a/crates/pecos-fusion-blossom/src/lib.rs b/crates/pecos-fusion-blossom/src/lib.rs index b4f09f3da..bbc122a18 100644 --- a/crates/pecos-fusion-blossom/src/lib.rs +++ b/crates/pecos-fusion-blossom/src/lib.rs @@ -19,6 +19,9 @@ pub mod errors; pub use builder::FusionBlossomBuilder; pub use decoder::{ DecodingOptions, DecodingResult, FusionBlossomConfig, FusionBlossomDecoder, - PerfectMatchingInfo, SolverType, StandardCode, SyndromeData, + ParsedCorrelatedDem, PerfectMatchingInfo, SolverType, StandardCode, SyndromeData, }; pub use errors::FusionBlossomError; + +// Re-export partition types from fusion-blossom for parallel solver +pub use fusion_blossom::util::{PartitionConfig, VertexRange}; diff --git a/crates/pecos-gpu-sims/examples/benchmark_influence_sampler.rs b/crates/pecos-gpu-sims/examples/benchmark_influence_sampler.rs index ae000b21e..dbd23c3e8 100644 --- a/crates/pecos-gpu-sims/examples/benchmark_influence_sampler.rs +++ b/crates/pecos-gpu-sims/examples/benchmark_influence_sampler.rs @@ -42,15 +42,15 @@ fn create_test_influence_map(num_locations: usize, num_detectors: usize) -> GpuI GpuInfluenceMapData { num_locations: num_locations as u32, num_detectors: num_detectors as u32, - num_logicals: 2, + num_dem_outputs: 2, detector_offsets_x, detector_data_x, detector_offsets_y, detector_data_y, detector_offsets_z, detector_data_z, - // Logicals: location 0 X -> log 0, location 1 X -> log 1 - logical_offsets_x: { + // DEM outputs: location 0 X -> L0, location 1 X -> L1 + dem_output_offsets_x: { let mut v = vec![0u32; num_locations + 1]; if num_locations > 0 { v[1] = 1; @@ -63,27 +63,27 @@ fn create_test_influence_map(num_locations: usize, num_detectors: usize) -> GpuI } v }, - logical_data_x: vec![0, 1], - logical_offsets_y: vec![0; num_locations + 1], - logical_data_y: vec![], - logical_offsets_z: vec![0; num_locations + 1], - logical_data_z: vec![], + dem_output_data_x: vec![0, 1], + dem_output_offsets_y: vec![0; num_locations + 1], + dem_output_data_y: vec![], + dem_output_offsets_z: vec![0; num_locations + 1], + dem_output_data_z: vec![], } } -/// Simple CPU sampler for comparison (mirrors the pecos-qec `NoisySampler` logic) +/// Simple CPU sampler for comparison (mirrors the pecos-qec `DemSampler` logic) struct CpuSampler { num_locations: usize, num_detectors: usize, - num_logicals: usize, + num_dem_outputs: usize, detector_offsets_x: Vec, detector_data_x: Vec, detector_offsets_y: Vec, detector_data_y: Vec, detector_offsets_z: Vec, detector_data_z: Vec, - logical_offsets_x: Vec, - logical_data_x: Vec, + dem_output_offsets_x: Vec, + dem_output_data_x: Vec, rng_state: u64, } @@ -92,15 +92,15 @@ impl CpuSampler { Self { num_locations: map.num_locations as usize, num_detectors: map.num_detectors as usize, - num_logicals: map.num_logicals as usize, + num_dem_outputs: map.num_dem_outputs as usize, detector_offsets_x: map.detector_offsets_x.clone(), detector_data_x: map.detector_data_x.clone(), detector_offsets_y: map.detector_offsets_y.clone(), detector_data_y: map.detector_data_y.clone(), detector_offsets_z: map.detector_offsets_z.clone(), detector_data_z: map.detector_data_z.clone(), - logical_offsets_x: map.logical_offsets_x.clone(), - logical_data_x: map.logical_data_x.clone(), + dem_output_offsets_x: map.dem_output_offsets_x.clone(), + dem_output_data_x: map.dem_output_data_x.clone(), rng_state: seed, } } @@ -120,11 +120,11 @@ impl CpuSampler { #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] // probability in [0,1] maps to [0, u32::MAX] let threshold = (p_error * f64::from(u32::MAX)) as u32; - let mut logical_errors = 0; + let mut logical_error_count = 0; for _ in 0..num_shots { let mut detector_flips = vec![0u8; self.num_detectors.max(1)]; - let mut logical_flips = vec![0u8; self.num_logicals.max(1)]; + let mut dem_output_flips = vec![0u8; self.num_dem_outputs.max(1)]; for loc in 0..self.num_locations { let rand = self.next_u32(); @@ -135,7 +135,7 @@ impl CpuSampler { // Sample Pauli type let pauli = self.next_u32() % 3; - // Get affected detectors and logicals + // Get affected detectors and DEM outputs let (det_start, det_end, det_data) = match pauli { 0 => ( self.detector_offsets_x[loc] as usize, @@ -161,25 +161,25 @@ impl CpuSampler { } } - // Only X affects logicals in our test map + // Only X affects DEM outputs in our test map if pauli == 0 { - let log_start = self.logical_offsets_x[loc] as usize; - let log_end = self.logical_offsets_x[loc + 1] as usize; - for i in log_start..log_end { - let log_idx = self.logical_data_x[i] as usize; - if log_idx < logical_flips.len() { - logical_flips[log_idx] ^= 1; + let dem_output_start = self.dem_output_offsets_x[loc] as usize; + let dem_output_end = self.dem_output_offsets_x[loc + 1] as usize; + for i in dem_output_start..dem_output_end { + let dem_output_idx = self.dem_output_data_x[i] as usize; + if dem_output_idx < dem_output_flips.len() { + dem_output_flips[dem_output_idx] ^= 1; } } } } - if logical_flips.contains(&1) { - logical_errors += 1; + if dem_output_flips.contains(&1) { + logical_error_count += 1; } } - logical_errors + logical_error_count } } @@ -195,8 +195,8 @@ fn benchmark_gpu( let result = sampler.sample_uniform(num_shots, p_error); let elapsed = start.elapsed(); - let logical_errors = result.count_logical_errors(); - (elapsed, logical_errors) + let logical_error_count = result.count_logical_errors(); + (elapsed, logical_error_count) } fn benchmark_cpu( @@ -208,10 +208,10 @@ fn benchmark_cpu( let mut sampler = CpuSampler::new(map, seed); let start = Instant::now(); - let logical_errors = sampler.sample(num_shots, p_error); + let logical_error_count = sampler.sample(num_shots, p_error); let elapsed = start.elapsed(); - (elapsed, logical_errors) + (elapsed, logical_error_count) } fn main() { diff --git a/crates/pecos-gpu-sims/examples/cpu_vs_gpu_comparison.rs b/crates/pecos-gpu-sims/examples/cpu_vs_gpu_comparison.rs index 1bdd2b61c..8bf404caa 100644 --- a/crates/pecos-gpu-sims/examples/cpu_vs_gpu_comparison.rs +++ b/crates/pecos-gpu-sims/examples/cpu_vs_gpu_comparison.rs @@ -4,7 +4,7 @@ use pecos_gpu_sims::{GpuInfluenceMapData, GpuInfluenceSampler}; use pecos_qec::fault_tolerance::InfluenceBuilder; -use pecos_qec::fault_tolerance::noisy_sampler::{NoisySampler, UniformNoiseModel}; +use pecos_qec::fault_tolerance::dem_builder::DemSampler; use pecos_quantum::DagCircuit; use std::time::Instant; @@ -91,8 +91,8 @@ fn main() { let num_data = distance * distance; // Build influence map - let logical_qubits: Vec = (0..num_data).collect(); - let builder = InfluenceBuilder::new(&circuit).with_logical_z(logical_qubits); + let tracked_pauli_qubits: Vec = (0..num_data).collect(); + let builder = InfluenceBuilder::new(&circuit).with_z(&tracked_pauli_qubits); let influence_map = builder.build(); let num_locations = influence_map.locations.len(); @@ -101,32 +101,46 @@ fn main() { let ( num_loc, num_det, - num_log, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ) = influence_map.export_csr(); let gpu_map = GpuInfluenceMapData::from_csr( - num_loc, num_det, num_log, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, - det_data_z, log_off_x, log_data_x, log_off_y, log_data_y, log_off_z, log_data_z, + num_loc, + num_det, + num_dem_outputs, + det_off_x, + det_data_x, + det_off_y, + det_data_y, + det_off_z, + det_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ); // CPU benchmark - let noise = UniformNoiseModel::depolarizing(p_error); - let mut cpu_sampler = NoisySampler::new(&influence_map, noise, seed); + let num_loc = influence_map.locations.len(); + let probs = vec![p_error; num_loc]; + let cpu_sampler = DemSampler::from_influence_map(&influence_map, &probs); let cpu_start = Instant::now(); - let _ = cpu_sampler.sample(num_shots as usize); + let _ = cpu_sampler.sample_statistics(num_shots as usize, seed); let cpu_time = cpu_start.elapsed(); // GPU benchmark (with warmup) @@ -165,38 +179,51 @@ fn main() { let circuit = build_surface_code_grid(distance, num_rounds); let num_data = distance * distance; - let logical_qubits: Vec = (0..num_data).collect(); - let builder = InfluenceBuilder::new(&circuit).with_logical_z(logical_qubits); + let tracked_pauli_qubits: Vec = (0..num_data).collect(); + let builder = InfluenceBuilder::new(&circuit).with_z(&tracked_pauli_qubits); let influence_map = builder.build(); let ( num_loc, num_det, - num_log, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ) = influence_map.export_csr(); let gpu_map = GpuInfluenceMapData::from_csr( - num_loc, num_det, num_log, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, - det_data_z, log_off_x, log_data_x, log_off_y, log_data_y, log_off_z, log_data_z, + num_loc, + num_det, + num_dem_outputs, + det_off_x, + det_data_x, + det_off_y, + det_data_y, + det_off_z, + det_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ); // CPU - let noise = UniformNoiseModel::depolarizing(p_error); - let mut cpu_sampler = NoisySampler::new(&influence_map, noise, seed); + let probs2 = vec![p_error; influence_map.locations.len()]; + let cpu_sampler = DemSampler::from_influence_map(&influence_map, &probs2); let cpu_start = Instant::now(); - let _ = cpu_sampler.sample(num_shots as usize); + let _ = cpu_sampler.sample_statistics(num_shots as usize, seed); let cpu_time = cpu_start.elapsed(); // GPU diff --git a/crates/pecos-gpu-sims/examples/full_pipeline_example.rs b/crates/pecos-gpu-sims/examples/full_pipeline_example.rs index 519d00e99..f882e1094 100644 --- a/crates/pecos-gpu-sims/examples/full_pipeline_example.rs +++ b/crates/pecos-gpu-sims/examples/full_pipeline_example.rs @@ -83,8 +83,8 @@ fn main() { let circuit = build_repetition_code_circuit(2); println!(" Circuit built: {} gates", circuit.gate_count()); - // Build influence map with logical Z (sensitive to X errors) - let builder = InfluenceBuilder::new(&circuit).with_logical_z(vec![0, 1, 2]); + // Build influence map with a tracked Z Pauli (sensitive to X errors) + let builder = InfluenceBuilder::new(&circuit).with_z(&[0, 1, 2]); let influence_map = builder.build(); println!(" Locations: {}", influence_map.locations.len()); @@ -95,41 +95,41 @@ fn main() { let ( num_locations, num_detectors, - num_logicals, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ) = influence_map.export_csr(); println!( - " Exported CSR: {num_locations} locations, {num_detectors} detectors, {num_logicals} logicals" + " Exported CSR: {num_locations} locations, {num_detectors} detectors, {num_dem_outputs} DEM outputs" ); let gpu_map = GpuInfluenceMapData::from_csr( num_locations, num_detectors, - num_logicals, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ); // Sample with GPU @@ -139,15 +139,15 @@ fn main() { let p_error = 0.001; // 0.1% error rate let result = sampler.sample_uniform(num_shots, p_error); - let logical_errors = result.count_logical_errors(); + let logical_error_count = result.count_logical_errors(); #[allow(clippy::cast_precision_loss)] // rate calculation - let error_rate = logical_errors as f64 / f64::from(num_shots); + let logical_error_rate = logical_error_count as f64 / f64::from(num_shots); println!( " GPU Sampling: {} shots, p={}, logical error rate: {:.4}%", num_shots, p_error, - error_rate * 100.0 + logical_error_rate * 100.0 ); // ========================================================================= @@ -158,8 +158,8 @@ fn main() { let circuit = build_surface_code_plaquette(3); println!(" Circuit built: {} gates", circuit.gate_count()); - // Build influence map with logical X (sensitive to Z errors on this plaquette) - let builder = InfluenceBuilder::new(&circuit).with_logical_x(vec![0, 1, 2, 3]); + // Build influence map with a tracked X Pauli (sensitive to Z errors on this plaquette) + let builder = InfluenceBuilder::new(&circuit).with_x(&[0, 1, 2, 3]); let influence_map = builder.build(); println!(" Locations: {}", influence_map.locations.len()); @@ -169,51 +169,51 @@ fn main() { let ( num_locations, num_detectors, - num_logicals, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ) = influence_map.export_csr(); let gpu_map = GpuInfluenceMapData::from_csr( num_locations, num_detectors, - num_logicals, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ); let mut sampler = GpuInfluenceSampler::new(&gpu_map, 42).expect("Failed to create GPU sampler"); let result = sampler.sample_uniform(num_shots, p_error); - let logical_errors = result.count_logical_errors(); + let logical_error_count = result.count_logical_errors(); #[allow(clippy::cast_precision_loss)] // rate calculation - let error_rate = logical_errors as f64 / f64::from(num_shots); + let logical_error_rate = logical_error_count as f64 / f64::from(num_shots); println!( " GPU Sampling: {} shots, p={}, logical error rate: {:.4}%", num_shots, p_error, - error_rate * 100.0 + logical_error_rate * 100.0 ); // ========================================================================= @@ -223,43 +223,43 @@ fn main() { for num_rounds in [1, 2, 4, 8] { let circuit = build_repetition_code_circuit(num_rounds); - let builder = InfluenceBuilder::new(&circuit).with_logical_z(vec![0, 1, 2]); + let builder = InfluenceBuilder::new(&circuit).with_z(&[0, 1, 2]); let influence_map = builder.build(); let ( num_locations, num_detectors, - num_logicals, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ) = influence_map.export_csr(); let gpu_map = GpuInfluenceMapData::from_csr( num_locations, num_detectors, - num_logicals, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ); let mut sampler = @@ -269,14 +269,14 @@ fn main() { let result = sampler.sample_uniform(100_000, 0.001); let elapsed = start.elapsed(); - let logical_errors = result.count_logical_errors(); + let logical_error_count = result.count_logical_errors(); println!( - " {} rounds: {} locations, {} detectors, {} logical errors, {:.2}ms", + " {} rounds: {} locations, {} detectors, {} logical error shots, {:.2}ms", num_rounds, num_locations, num_detectors, - logical_errors, + logical_error_count, elapsed.as_secs_f64() * 1000.0 ); } diff --git a/crates/pecos-gpu-sims/examples/pipeline_benchmark.rs b/crates/pecos-gpu-sims/examples/pipeline_benchmark.rs index 387ba372f..3b28b931a 100644 --- a/crates/pecos-gpu-sims/examples/pipeline_benchmark.rs +++ b/crates/pecos-gpu-sims/examples/pipeline_benchmark.rs @@ -9,7 +9,7 @@ use pecos_gpu_sims::{GpuInfluenceMapData, GpuInfluenceSampler}; use pecos_qec::fault_tolerance::InfluenceBuilder; -use pecos_qec::fault_tolerance::noisy_sampler::{NoisySampler, UniformNoiseModel}; +use pecos_qec::fault_tolerance::dem_builder::DemSampler; use pecos_quantum::DagCircuit; use std::time::{Duration, Instant}; @@ -127,8 +127,8 @@ struct BenchmarkResult { build_time: Duration, cpu_time: Duration, gpu_time: Duration, - _cpu_logical_errors: usize, - _gpu_logical_errors: usize, + _cpu_logical_error_count: usize, + _gpu_logical_error_count: usize, } impl BenchmarkResult { @@ -150,14 +150,14 @@ impl BenchmarkResult { fn benchmark_circuit( name: &str, circuit: &DagCircuit, - logical_qubits: Vec, + tracked_pauli_qubits: &[usize], num_shots: u32, p_error: f64, seed: u64, ) -> BenchmarkResult { // Build influence map (common to both pipelines) let build_start = Instant::now(); - let builder = InfluenceBuilder::new(circuit).with_logical_z(logical_qubits); + let builder = InfluenceBuilder::new(circuit).with_z(tracked_pauli_qubits); let influence_map = builder.build(); let build_time = build_start.elapsed(); @@ -165,37 +165,50 @@ fn benchmark_circuit( let num_detectors = influence_map.detectors.len(); // CPU sampling - let noise = UniformNoiseModel::depolarizing(p_error); - let mut cpu_sampler = NoisySampler::new(&influence_map, noise, seed); + let probs = vec![p_error; num_locations]; + let cpu_sampler = DemSampler::from_influence_map(&influence_map, &probs); let cpu_start = Instant::now(); - let cpu_results = cpu_sampler.sample(num_shots as usize); + let cpu_stats = cpu_sampler.sample_statistics(num_shots as usize, seed); let cpu_time = cpu_start.elapsed(); - let cpu_logical_errors = cpu_results.iter().filter(|r| r.has_logical_error()).count(); + let cpu_logical_error_count = cpu_stats.logical_error_count; // GPU sampling let ( num_loc, num_det, - num_log, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ) = influence_map.export_csr(); let gpu_map = GpuInfluenceMapData::from_csr( - num_loc, num_det, num_log, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, - det_data_z, log_off_x, log_data_x, log_off_y, log_data_y, log_off_z, log_data_z, + num_loc, + num_det, + num_dem_outputs, + det_off_x, + det_data_x, + det_off_y, + det_data_y, + det_off_z, + det_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ); let mut gpu_sampler = @@ -208,7 +221,7 @@ fn benchmark_circuit( let gpu_result = gpu_sampler.sample_uniform(num_shots, p_error); let gpu_time = gpu_start.elapsed(); - let gpu_logical_errors = gpu_result.count_logical_errors(); + let gpu_logical_error_count = gpu_result.count_logical_errors(); BenchmarkResult { name: name.to_string(), @@ -218,8 +231,8 @@ fn benchmark_circuit( build_time, cpu_time, gpu_time, - _cpu_logical_errors: cpu_logical_errors, - _gpu_logical_errors: gpu_logical_errors, + _cpu_logical_error_count: cpu_logical_error_count, + _gpu_logical_error_count: gpu_logical_error_count, } } @@ -283,10 +296,17 @@ fn main() { for (num_data, num_rounds) in [(3, 2), (5, 3), (7, 4), (9, 5), (11, 6), (15, 8)] { let circuit = build_repetition_code(num_data, num_rounds); - let logical_qubits: Vec = (0..num_data).collect(); + let tracked_pauli_qubits: Vec = (0..num_data).collect(); let name = format!("rep_d{num_data}r{num_rounds}"); - let result = benchmark_circuit(&name, &circuit, logical_qubits, num_shots, p_error, seed); + let result = benchmark_circuit( + &name, + &circuit, + &tracked_pauli_qubits, + num_shots, + p_error, + seed, + ); results.push(result); } @@ -298,7 +318,7 @@ fn main() { println!("\nTest 2: Fixed Circuit (rep_d7r4) - Varying Shots\n"); let circuit = build_repetition_code(7, 4); - let logical_qubits: Vec = (0..7).collect(); + let tracked_pauli_qubits: Vec = (0..7).collect(); let mut shot_results = Vec::new(); @@ -307,7 +327,7 @@ fn main() { let result = benchmark_circuit( &name, &circuit, - logical_qubits.clone(), + &tracked_pauli_qubits, num_shots, p_error, seed, @@ -328,10 +348,17 @@ fn main() { for (distance, rounds) in [(3, 2), (4, 2), (5, 3), (6, 3), (7, 4)] { let circuit = build_surface_code_grid(distance, rounds); let num_data = distance * distance; - let logical_qubits: Vec = (0..num_data).collect(); + let tracked_pauli_qubits: Vec = (0..num_data).collect(); let name = format!("surf_d{distance}r{rounds}"); - let result = benchmark_circuit(&name, &circuit, logical_qubits, num_shots, p_error, seed); + let result = benchmark_circuit( + &name, + &circuit, + &tracked_pauli_qubits, + num_shots, + p_error, + seed, + ); surface_results.push(result); } diff --git a/crates/pecos-gpu-sims/examples/profile_samplers.rs b/crates/pecos-gpu-sims/examples/profile_samplers.rs index 54d979fc0..cefa9cd37 100644 --- a/crates/pecos-gpu-sims/examples/profile_samplers.rs +++ b/crates/pecos-gpu-sims/examples/profile_samplers.rs @@ -5,9 +5,7 @@ use bytemuck::{Pod, Zeroable}; use pecos_gpu_sims::GpuInfluenceMapData; use pecos_qec::fault_tolerance::InfluenceBuilder; -use pecos_qec::fault_tolerance::noisy_sampler::{ - FastNoisySampler, NoisySampler, UniformNoiseModel, -}; +use pecos_qec::fault_tolerance::dem_builder::DemSampler; use pecos_quantum::DagCircuit; use pecos_random::PecosRng; use std::time::Instant; @@ -74,19 +72,18 @@ fn build_surface_code_grid(distance: usize, num_rounds: usize) -> DagCircuit { dag } -/// Profile the CPU sampler with detailed timing +/// Profile the CPU `DemSampler` with detailed timing fn profile_cpu_sampler( influence_map: &pecos_qec::fault_tolerance::DagFaultInfluenceMap, p_error: f64, seed: u64, num_shots: usize, ) -> CpuProfile { - let noise = UniformNoiseModel::depolarizing(p_error); - let mut sampler = NoisySampler::new(influence_map, noise, seed); + let probs = vec![p_error; influence_map.locations.len()]; + let sampler = DemSampler::from_influence_map(influence_map, &probs); - // Time the actual sampling let start = Instant::now(); - let _results = sampler.sample(num_shots); + let _stats = sampler.sample_statistics(num_shots, seed); let total_time = start.elapsed(); CpuProfile { @@ -103,27 +100,6 @@ struct CpuProfile { locations: usize, } -/// Profile the optimized `FastNoisySampler` -fn profile_fast_cpu_sampler( - influence_map: &pecos_qec::fault_tolerance::DagFaultInfluenceMap, - p_error: f64, - seed: u64, - num_shots: usize, -) -> CpuProfile { - let mut sampler = FastNoisySampler::new(influence_map, p_error, seed); - - // Time the actual sampling - let start = Instant::now(); - let _results = sampler.sample(num_shots); - let total_time = start.elapsed(); - - CpuProfile { - total_ms: total_time.as_secs_f64() * 1000.0, - shots: num_shots, - locations: influence_map.locations.len(), - } -} - /// Parameters for the sampling shader #[repr(C)] #[derive(Copy, Clone, Debug, Pod, Zeroable)] @@ -131,10 +107,10 @@ struct SamplerParams { num_locations: u32, num_shots: u32, num_detectors: u32, - num_logicals: u32, + num_dem_outputs: u32, p_error_threshold: u32, detector_words: u32, - logical_words: u32, + dem_output_words: u32, _padding: u32, } @@ -188,12 +164,12 @@ fn profile_gpu_sampler( let detector_data_y_buffer = create_buffer(&gpu_map.detector_data_y, "DetDataY"); let detector_offsets_z_buffer = create_buffer(&gpu_map.detector_offsets_z, "DetOffZ"); let detector_data_z_buffer = create_buffer(&gpu_map.detector_data_z, "DetDataZ"); - let logical_offsets_x_buffer = create_buffer(&gpu_map.logical_offsets_x, "LogOffX"); - let logical_data_x_buffer = create_buffer(&gpu_map.logical_data_x, "LogDataX"); - let logical_offsets_y_buffer = create_buffer(&gpu_map.logical_offsets_y, "LogOffY"); - let logical_data_y_buffer = create_buffer(&gpu_map.logical_data_y, "LogDataY"); - let logical_offsets_z_buffer = create_buffer(&gpu_map.logical_offsets_z, "LogOffZ"); - let logical_data_z_buffer = create_buffer(&gpu_map.logical_data_z, "LogDataZ"); + let dem_output_offsets_x_buffer = create_buffer(&gpu_map.dem_output_offsets_x, "DemOutOffX"); + let dem_output_data_x_buffer = create_buffer(&gpu_map.dem_output_data_x, "DemOutDataX"); + let dem_output_offsets_y_buffer = create_buffer(&gpu_map.dem_output_offsets_y, "DemOutOffY"); + let dem_output_data_y_buffer = create_buffer(&gpu_map.dem_output_data_y, "DemOutDataY"); + let dem_output_offsets_z_buffer = create_buffer(&gpu_map.dem_output_offsets_z, "DemOutOffZ"); + let dem_output_data_z_buffer = create_buffer(&gpu_map.dem_output_data_z, "DemOutDataZ"); let upload_map_time = upload_map_start.elapsed(); // Phase 3: Create shader and pipeline (done once) @@ -245,7 +221,7 @@ fn profile_gpu_sampler( // Phase 4: Create params buffer let params_start = Instant::now(); let detector_words = gpu_map.num_detectors.div_ceil(32).max(1); - let logical_words = gpu_map.num_logicals.div_ceil(32).max(1); + let dem_output_words = gpu_map.num_dem_outputs.div_ceil(32).max(1); #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] // probability in [0,1] maps to [0, u32::MAX] let p_threshold = (p_error * f64::from(u32::MAX)) as u32; @@ -253,10 +229,10 @@ fn profile_gpu_sampler( num_locations: gpu_map.num_locations, num_shots, num_detectors: gpu_map.num_detectors, - num_logicals: gpu_map.num_logicals, + num_dem_outputs: gpu_map.num_dem_outputs, p_error_threshold: p_threshold, detector_words, - logical_words, + dem_output_words, _padding: 0, }; let params_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -282,7 +258,7 @@ fn profile_gpu_sampler( // Phase 6: Create output buffers let output_start = Instant::now(); let detector_output_size = (num_shots as usize * detector_words as usize * 4) as u64; - let logical_output_size = (num_shots as usize * logical_words as usize * 4) as u64; + let dem_output_size = (num_shots as usize * dem_output_words as usize * 4) as u64; let detector_output_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Detector Output"), @@ -291,9 +267,9 @@ fn profile_gpu_sampler( mapped_at_creation: false, }); - let logical_output_buffer = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Logical Output"), - size: logical_output_size.max(4), + let dem_output_buffer = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("DEM Output"), + size: dem_output_size.max(4), usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, }); @@ -335,27 +311,27 @@ fn profile_gpu_sampler( }, wgpu::BindGroupEntry { binding: 7, - resource: logical_offsets_x_buffer.as_entire_binding(), + resource: dem_output_offsets_x_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 8, - resource: logical_data_x_buffer.as_entire_binding(), + resource: dem_output_data_x_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 9, - resource: logical_offsets_y_buffer.as_entire_binding(), + resource: dem_output_offsets_y_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 10, - resource: logical_data_y_buffer.as_entire_binding(), + resource: dem_output_data_y_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 11, - resource: logical_offsets_z_buffer.as_entire_binding(), + resource: dem_output_offsets_z_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 12, - resource: logical_data_z_buffer.as_entire_binding(), + resource: dem_output_data_z_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 13, @@ -367,7 +343,7 @@ fn profile_gpu_sampler( }, wgpu::BindGroupEntry { binding: 15, - resource: logical_output_buffer.as_entire_binding(), + resource: dem_output_buffer.as_entire_binding(), }, ], }); @@ -404,9 +380,9 @@ fn profile_gpu_sampler( usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, }); - let logical_staging = device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Logical Staging"), - size: logical_output_size.max(4), + let dem_output_staging = device.create_buffer(&wgpu::BufferDescriptor { + label: Some("DEM Output Staging"), + size: dem_output_size.max(4), usage: wgpu::BufferUsages::COPY_DST | wgpu::BufferUsages::MAP_READ, mapped_at_creation: false, }); @@ -420,11 +396,11 @@ fn profile_gpu_sampler( detector_output_size.max(4), ); encoder.copy_buffer_to_buffer( - &logical_output_buffer, + &dem_output_buffer, 0, - &logical_staging, + &dem_output_staging, 0, - logical_output_size.max(4), + dem_output_size.max(4), ); queue.submit(std::iter::once(encoder.finish())); @@ -435,9 +411,9 @@ fn profile_gpu_sampler( tx1.send(result).unwrap(); }); - let log_slice = logical_staging.slice(..); + let dem_output_slice = dem_output_staging.slice(..); let (tx2, rx2) = std::sync::mpsc::channel(); - log_slice.map_async(wgpu::MapMode::Read, move |result| { + dem_output_slice.map_async(wgpu::MapMode::Read, move |result| { tx2.send(result).unwrap(); }); @@ -450,9 +426,9 @@ fn profile_gpu_sampler( let _det_results: Vec = bytemuck::cast_slice(&det_data).to_vec(); drop(det_data); - let log_data = log_slice.get_mapped_range(); - let _log_results: Vec = bytemuck::cast_slice(&log_data).to_vec(); - drop(log_data); + let dem_output_data = dem_output_slice.get_mapped_range(); + let _dem_output_results: Vec = bytemuck::cast_slice(&dem_output_data).to_vec(); + drop(dem_output_data); let read_time = read_start.elapsed(); @@ -471,7 +447,7 @@ fn profile_gpu_sampler( #[allow(clippy::cast_possible_truncation)] // 64-bit target detector_output_bytes: detector_output_size as usize, #[allow(clippy::cast_possible_truncation)] // 64-bit target - logical_output_bytes: logical_output_size as usize, + dem_output_bytes: dem_output_size as usize, } } @@ -489,7 +465,7 @@ struct GpuProfile { #[allow(dead_code)] shots: usize, detector_output_bytes: usize, - logical_output_bytes: usize, + dem_output_bytes: usize, } impl GpuProfile { @@ -536,8 +512,8 @@ fn main() { let num_data = distance * distance; // Build influence map - let logical_qubits: Vec = (0..num_data).collect(); - let builder = InfluenceBuilder::new(&circuit).with_logical_z(logical_qubits); + let tracked_pauli_qubits: Vec = (0..num_data).collect(); + let builder = InfluenceBuilder::new(&circuit).with_z(&tracked_pauli_qubits); let influence_map = builder.build(); let num_locations = influence_map.locations.len(); @@ -545,24 +521,37 @@ fn main() { let ( num_loc, num_det, - num_log, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ) = influence_map.export_csr(); let gpu_map = GpuInfluenceMapData::from_csr( - num_loc, num_det, num_log, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, - det_data_z, log_off_x, log_data_x, log_off_y, log_data_y, log_off_z, log_data_z, + num_loc, + num_det, + num_dem_outputs, + det_off_x, + det_data_x, + det_off_y, + det_data_y, + det_off_z, + det_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ); println!( @@ -570,9 +559,9 @@ fn main() { ); println!("{:-<70}", ""); - // CPU profile (original) + // CPU profile (DemSampler) let cpu = profile_cpu_sampler(&influence_map, p_error, seed, num_shots as usize); - println!("\nCPU Pipeline (Original NoisySampler):"); + println!("\nCPU Pipeline (DemSampler):"); println!(" Total time: {:>10.2} ms", cpu.total_ms); println!( " Per-shot: {:>10.2} us", @@ -583,21 +572,6 @@ fn main() { cpu.shots as f64 / cpu.total_ms / 1000.0 ); - // CPU profile (fast/optimized) - let cpu_fast = profile_fast_cpu_sampler(&influence_map, p_error, seed, num_shots as usize); - println!("\nCPU Pipeline (FastNoisySampler - PecosRng + Sparse):"); - println!(" Total time: {:>10.2} ms", cpu_fast.total_ms); - println!( - " Per-shot: {:>10.2} us", - cpu_fast.total_ms * 1000.0 / cpu_fast.shots as f64 - ); - println!( - " Throughput: {:>10.3} M shots/sec", - cpu_fast.shots as f64 / cpu_fast.total_ms / 1000.0 - ); - let cpu_speedup = cpu.total_ms / cpu_fast.total_ms; - println!(" Speedup vs original: {cpu_speedup:>10.2}x"); - // GPU profile let gpu = profile_gpu_sampler(&gpu_map, p_error, seed, num_shots); println!("\nGPU Pipeline:"); @@ -617,22 +591,17 @@ fn main() { println!(" Subtotal (per-call): {:>10.2} ms", gpu.per_sample_ms()); println!(" Total: {:>10.2} ms", gpu.total_ms()); println!( - " Output size: {:>10.2} KB (det) + {:.2} KB (log)", + " Output size: {:>10.2} KB (det) + {:.2} KB (DEM out)", gpu.detector_output_bytes as f64 / 1024.0, - gpu.logical_output_bytes as f64 / 1024.0 + gpu.dem_output_bytes as f64 / 1024.0 ); println!("\nComparison:"); - println!(" CPU (original): {:>10.2} ms", cpu.total_ms); - println!(" CPU (fast): {:>10.2} ms", cpu_fast.total_ms); + println!(" CPU (DemSampler): {:>10.2} ms", cpu.total_ms); println!(" GPU total (with init): {:>10.2} ms", gpu.total_ms()); println!(" GPU per-call only: {:>10.2} ms", gpu.per_sample_ms()); - let speedup_fast_vs_orig = cpu.total_ms / cpu_fast.total_ms; - let speedup_gpu_vs_orig = cpu.total_ms / gpu.per_sample_ms(); - let speedup_gpu_vs_fast = cpu_fast.total_ms / gpu.per_sample_ms(); - println!(" Fast CPU vs Original: {speedup_fast_vs_orig:>10.1}x"); - println!(" GPU vs Original CPU: {speedup_gpu_vs_orig:>10.1}x"); - println!(" GPU vs Fast CPU: {speedup_gpu_vs_fast:>10.1}x"); + let speedup_gpu_vs_cpu = cpu.total_ms / gpu.per_sample_ms(); + println!(" GPU vs CPU: {speedup_gpu_vs_cpu:>10.1}x"); println!("\n"); } diff --git a/crates/pecos-gpu-sims/src/gpu_influence_sampler.rs b/crates/pecos-gpu-sims/src/gpu_influence_sampler.rs index b550c22ad..b3d1024ba 100644 --- a/crates/pecos-gpu-sims/src/gpu_influence_sampler.rs +++ b/crates/pecos-gpu-sims/src/gpu_influence_sampler.rs @@ -11,14 +11,14 @@ use wgpu::util::DeviceExt; /// Influence map data for GPU sampling. /// /// Contains CSR (Compressed Sparse Row) arrays mapping fault locations -/// to their detector and logical influences for X, Y, and Z Pauli faults. +/// to their detector and DEM-output influences for X, Y, and Z Pauli faults. pub struct GpuInfluenceMapData { /// Number of fault locations. pub num_locations: u32, /// Number of detectors. pub num_detectors: u32, - /// Number of logicals. - pub num_logicals: u32, + /// Number of DEM `L` outputs. + pub num_dem_outputs: u32, // CSR arrays for detector influences /// Offsets for X detector influences: `offsets_x`[loc] to `offsets_x`[loc+1] @@ -34,19 +34,19 @@ pub struct GpuInfluenceMapData { /// Detector indices for Z faults. pub detector_data_z: Vec, - // CSR arrays for logical influences - /// Offsets for X logical influences. - pub logical_offsets_x: Vec, - /// Logical indices for X faults. - pub logical_data_x: Vec, - /// Offsets for Y logical influences. - pub logical_offsets_y: Vec, - /// Logical indices for Y faults. - pub logical_data_y: Vec, - /// Offsets for Z logical influences. - pub logical_offsets_z: Vec, - /// Logical indices for Z faults. - pub logical_data_z: Vec, + // CSR arrays for DEM-output influences + /// Offsets for X DEM-output influences. + pub dem_output_offsets_x: Vec, + /// DEM-output indices for X faults. + pub dem_output_data_x: Vec, + /// Offsets for Y DEM-output influences. + pub dem_output_offsets_y: Vec, + /// DEM-output indices for Y faults. + pub dem_output_data_y: Vec, + /// Offsets for Z DEM-output influences. + pub dem_output_offsets_z: Vec, + /// DEM-output indices for Z faults. + pub dem_output_data_z: Vec, } impl GpuInfluenceMapData { @@ -56,19 +56,19 @@ impl GpuInfluenceMapData { Self { num_locations: 0, num_detectors: 0, - num_logicals: 0, + num_dem_outputs: 0, detector_offsets_x: vec![0], detector_data_x: vec![], detector_offsets_y: vec![0], detector_data_y: vec![], detector_offsets_z: vec![0], detector_data_z: vec![], - logical_offsets_x: vec![0], - logical_data_x: vec![], - logical_offsets_y: vec![0], - logical_data_y: vec![], - logical_offsets_z: vec![0], - logical_data_z: vec![], + dem_output_offsets_x: vec![0], + dem_output_data_x: vec![], + dem_output_offsets_y: vec![0], + dem_output_data_y: vec![], + dem_output_offsets_z: vec![0], + dem_output_data_z: vec![], } } @@ -78,36 +78,36 @@ impl GpuInfluenceMapData { pub fn from_csr( num_locations: u32, num_detectors: u32, - num_logicals: u32, + num_dem_outputs: u32, detector_offsets_x: Vec, detector_data_x: Vec, detector_offsets_y: Vec, detector_data_y: Vec, detector_offsets_z: Vec, detector_data_z: Vec, - logical_offsets_x: Vec, - logical_data_x: Vec, - logical_offsets_y: Vec, - logical_data_y: Vec, - logical_offsets_z: Vec, - logical_data_z: Vec, + dem_output_offsets_x: Vec, + dem_output_data_x: Vec, + dem_output_offsets_y: Vec, + dem_output_data_y: Vec, + dem_output_offsets_z: Vec, + dem_output_data_z: Vec, ) -> Self { Self { num_locations, num_detectors, - num_logicals, + num_dem_outputs, detector_offsets_x, detector_data_x, detector_offsets_y, detector_data_y, detector_offsets_z, detector_data_z, - logical_offsets_x, - logical_data_x, - logical_offsets_y, - logical_data_y, - logical_offsets_z, - logical_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, } } } @@ -119,10 +119,10 @@ struct SamplerParams { num_locations: u32, num_shots: u32, num_detectors: u32, - num_logicals: u32, + num_dem_outputs: u32, p_error_threshold: u32, detector_words: u32, - logical_words: u32, + dem_output_words: u32, _padding: u32, } @@ -133,7 +133,7 @@ struct SamplerParams { pub struct GpuInfluenceSampler { num_locations: u32, num_detectors: u32, - num_logicals: u32, + num_dem_outputs: u32, device: wgpu::Device, queue: wgpu::Queue, @@ -147,12 +147,12 @@ pub struct GpuInfluenceSampler { detector_data_y_buffer: wgpu::Buffer, detector_offsets_z_buffer: wgpu::Buffer, detector_data_z_buffer: wgpu::Buffer, - logical_offsets_x_buffer: wgpu::Buffer, - logical_data_x_buffer: wgpu::Buffer, - logical_offsets_y_buffer: wgpu::Buffer, - logical_data_y_buffer: wgpu::Buffer, - logical_offsets_z_buffer: wgpu::Buffer, - logical_data_z_buffer: wgpu::Buffer, + dem_output_offsets_x_buffer: wgpu::Buffer, + dem_output_data_x_buffer: wgpu::Buffer, + dem_output_offsets_y_buffer: wgpu::Buffer, + dem_output_data_y_buffer: wgpu::Buffer, + dem_output_offsets_z_buffer: wgpu::Buffer, + dem_output_data_z_buffer: wgpu::Buffer, bind_group_layout: wgpu::BindGroupLayout, pipeline: wgpu::ComputePipeline, @@ -199,22 +199,22 @@ impl GpuInfluenceSampler { let detector_data_y_buffer = create_buffer(&map.detector_data_y, "DetDataY"); let detector_offsets_z_buffer = create_buffer(&map.detector_offsets_z, "DetOffZ"); let detector_data_z_buffer = create_buffer(&map.detector_data_z, "DetDataZ"); - let logical_offsets_x_buffer = create_buffer(&map.logical_offsets_x, "LogOffX"); - let logical_data_x_buffer = create_buffer(&map.logical_data_x, "LogDataX"); - let logical_offsets_y_buffer = create_buffer(&map.logical_offsets_y, "LogOffY"); - let logical_data_y_buffer = create_buffer(&map.logical_data_y, "LogDataY"); - let logical_offsets_z_buffer = create_buffer(&map.logical_offsets_z, "LogOffZ"); - let logical_data_z_buffer = create_buffer(&map.logical_data_z, "LogDataZ"); + let dem_output_offsets_x_buffer = create_buffer(&map.dem_output_offsets_x, "DemOutOffX"); + let dem_output_data_x_buffer = create_buffer(&map.dem_output_data_x, "DemOutDataX"); + let dem_output_offsets_y_buffer = create_buffer(&map.dem_output_offsets_y, "DemOutOffY"); + let dem_output_data_y_buffer = create_buffer(&map.dem_output_data_y, "DemOutDataY"); + let dem_output_offsets_z_buffer = create_buffer(&map.dem_output_offsets_z, "DemOutOffZ"); + let dem_output_data_z_buffer = create_buffer(&map.dem_output_data_z, "DemOutDataZ"); // Create params buffer let params = SamplerParams { num_locations: map.num_locations, num_shots: 0, num_detectors: map.num_detectors, - num_logicals: map.num_logicals, + num_dem_outputs: map.num_dem_outputs, p_error_threshold: 0, detector_words: 0, - logical_words: 0, + dem_output_words: 0, _padding: 0, }; let params_buffer = device.create_buffer_init(&wgpu::util::BufferInitDescriptor { @@ -419,7 +419,7 @@ impl GpuInfluenceSampler { Ok(Self { num_locations: map.num_locations, num_detectors: map.num_detectors, - num_logicals: map.num_logicals, + num_dem_outputs: map.num_dem_outputs, device, queue, params_buffer, @@ -429,12 +429,12 @@ impl GpuInfluenceSampler { detector_data_y_buffer, detector_offsets_z_buffer, detector_data_z_buffer, - logical_offsets_x_buffer, - logical_data_x_buffer, - logical_offsets_y_buffer, - logical_data_y_buffer, - logical_offsets_z_buffer, - logical_data_z_buffer, + dem_output_offsets_x_buffer, + dem_output_data_x_buffer, + dem_output_offsets_y_buffer, + dem_output_data_y_buffer, + dem_output_offsets_z_buffer, + dem_output_data_z_buffer, bind_group_layout, pipeline, rng: PecosRng::seed_from_u64(seed), @@ -444,7 +444,7 @@ impl GpuInfluenceSampler { /// Sample with uniform depolarizing noise. pub fn sample_uniform(&mut self, num_shots: u32, p_error: f64) -> GpuSamplingResult { let detector_words = self.num_detectors.div_ceil(32).max(1); - let logical_words = self.num_logicals.div_ceil(32).max(1); + let dem_output_words = self.num_dem_outputs.div_ceil(32).max(1); // Update params #[allow(clippy::cast_sign_loss, clippy::cast_possible_truncation)] @@ -454,10 +454,10 @@ impl GpuInfluenceSampler { num_locations: self.num_locations, num_shots, num_detectors: self.num_detectors, - num_logicals: self.num_logicals, + num_dem_outputs: self.num_dem_outputs, p_error_threshold: p_threshold, detector_words, - logical_words, + dem_output_words, _padding: 0, }; self.queue @@ -476,7 +476,7 @@ impl GpuInfluenceSampler { // Create output buffers - layout: [shot * words + word_idx] let detector_output_size = (num_shots as usize * detector_words as usize * 4) as u64; - let logical_output_size = (num_shots as usize * logical_words as usize * 4) as u64; + let dem_output_size = (num_shots as usize * dem_output_words as usize * 4) as u64; let detector_output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { label: Some("Detector Output"), @@ -485,9 +485,9 @@ impl GpuInfluenceSampler { mapped_at_creation: false, }); - let logical_output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { - label: Some("Logical Output"), - size: logical_output_size.max(4), + let dem_output_buffer = self.device.create_buffer(&wgpu::BufferDescriptor { + label: Some("DEM Output"), + size: dem_output_size.max(4), usage: wgpu::BufferUsages::STORAGE | wgpu::BufferUsages::COPY_SRC, mapped_at_creation: false, }); @@ -527,27 +527,27 @@ impl GpuInfluenceSampler { }, wgpu::BindGroupEntry { binding: 7, - resource: self.logical_offsets_x_buffer.as_entire_binding(), + resource: self.dem_output_offsets_x_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 8, - resource: self.logical_data_x_buffer.as_entire_binding(), + resource: self.dem_output_data_x_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 9, - resource: self.logical_offsets_y_buffer.as_entire_binding(), + resource: self.dem_output_offsets_y_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 10, - resource: self.logical_data_y_buffer.as_entire_binding(), + resource: self.dem_output_data_y_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 11, - resource: self.logical_offsets_z_buffer.as_entire_binding(), + resource: self.dem_output_offsets_z_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 12, - resource: self.logical_data_z_buffer.as_entire_binding(), + resource: self.dem_output_data_z_buffer.as_entire_binding(), }, wgpu::BindGroupEntry { binding: 13, @@ -559,7 +559,7 @@ impl GpuInfluenceSampler { }, wgpu::BindGroupEntry { binding: 15, - resource: logical_output_buffer.as_entire_binding(), + resource: dem_output_buffer.as_entire_binding(), }, ], }); @@ -591,20 +591,20 @@ impl GpuInfluenceSampler { num_shots as usize, detector_words as usize, ); - let logical_flips = self.read_output( - &logical_output_buffer, + let dem_output_flips = self.read_output( + &dem_output_buffer, num_shots as usize, - logical_words as usize, + dem_output_words as usize, ); GpuSamplingResult { num_shots: num_shots as usize, detector_flips, - logical_flips, + dem_output_flips, num_detectors: self.num_detectors as usize, - num_logicals: self.num_logicals as usize, + num_dem_outputs: self.num_dem_outputs as usize, detector_words: detector_words as usize, - logical_words: logical_words as usize, + dem_output_words: dem_output_words as usize, } } @@ -652,42 +652,61 @@ pub struct GpuSamplingResult { pub num_shots: usize, /// Flat array: [shot * `detector_words` + word] pub detector_flips: Vec, - /// Flat array: [shot * `logical_words` + word] - pub logical_flips: Vec, + /// Flat array: [shot * `dem_output_words` + word] + pub dem_output_flips: Vec, pub num_detectors: usize, - pub num_logicals: usize, + pub num_dem_outputs: usize, pub detector_words: usize, - pub logical_words: usize, + pub dem_output_words: usize, } impl GpuSamplingResult { + fn logical_word_mask(&self, word_idx: usize) -> u32 { + if self.num_dem_outputs == 0 || word_idx >= self.dem_output_words { + return 0; + } + let remaining = self.num_dem_outputs.saturating_sub(word_idx * 32); + if remaining >= 32 { + u32::MAX + } else if remaining == 0 { + 0 + } else { + (1u32 << remaining) - 1 + } + } + /// Count shots with any logical error. #[must_use] pub fn count_logical_errors(&self) -> usize { - if self.num_logicals == 0 { + if self.num_dem_outputs == 0 { return 0; } let mut count = 0; for shot in 0..self.num_shots { - let base = shot * self.logical_words; - let has_error = (0..self.logical_words) - .any(|w| self.logical_flips.get(base + w).copied().unwrap_or(0) != 0); - if has_error { + let base = shot * self.dem_output_words; + let has_flip = (0..self.dem_output_words).any(|w| { + let word = self.dem_output_flips.get(base + w).copied().unwrap_or(0); + (word & self.logical_word_mask(w)) != 0 + }); + if has_flip { count += 1; } } count } - /// Check if a specific shot has a logical error. + /// Check if a specific shot has any logical error. #[must_use] pub fn has_logical_error(&self, shot: usize) -> bool { - if shot >= self.num_shots || self.num_logicals == 0 { + if shot >= self.num_shots || self.num_dem_outputs == 0 { return false; } - let base = shot * self.logical_words; - (0..self.logical_words).any(|w| self.logical_flips.get(base + w).copied().unwrap_or(0) != 0) + let base = shot * self.dem_output_words; + (0..self.dem_output_words).any(|w| { + let word = self.dem_output_flips.get(base + w).copied().unwrap_or(0); + (word & self.logical_word_mask(w)) != 0 + }) } /// Get detector flip bits for a specific shot. diff --git a/crates/pecos-gpu-sims/src/gpu_noisy_sampler.rs b/crates/pecos-gpu-sims/src/gpu_noisy_sampler.rs index 7e7703f1d..fd6d752d8 100644 --- a/crates/pecos-gpu-sims/src/gpu_noisy_sampler.rs +++ b/crates/pecos-gpu-sims/src/gpu_noisy_sampler.rs @@ -326,9 +326,9 @@ impl NoiseSampler for BiasedDepolarizingNoiseSampler { } } -/// Operations that can be added to a circuit. +/// Ideal gates that can be added to a noisy sampler circuit. #[derive(Debug, Clone)] -pub enum CircuitOp { +pub enum Gate { // Single-qubit gates H(usize), S(usize), @@ -344,29 +344,39 @@ pub enum CircuitOp { // Measurements Mz(usize), +} - // Noise injection points - Noise1Q(usize), - Noise2Q(usize, usize), +/// Explicit noise injection point in a noisy sampler circuit. +#[derive(Debug, Clone)] +pub enum NoiseInjection { + OneQ(usize), + TwoQ(usize, usize), +} + +/// A circuit step for the noisy sampler. +#[derive(Debug, Clone)] +pub enum NoisyCircuitStep { + Gate(Gate), + Noise(NoiseInjection), } /// Builder for constructing circuits with noise injection points. #[derive(Default)] pub struct CircuitBuilder { - ops: Vec, + steps: Vec, } impl CircuitBuilder { /// Create a new empty circuit builder. #[must_use] pub fn new() -> Self { - Self { ops: Vec::new() } + Self { steps: Vec::new() } } /// Hadamard gate. pub fn h(&mut self, qubits: &[usize]) -> &mut Self { for &q in qubits { - self.ops.push(CircuitOp::H(q)); + self.steps.push(NoisyCircuitStep::Gate(Gate::H(q))); } self } @@ -374,7 +384,7 @@ impl CircuitBuilder { /// S gate (sqrt Z). pub fn s(&mut self, qubits: &[usize]) -> &mut Self { for &q in qubits { - self.ops.push(CircuitOp::S(q)); + self.steps.push(NoisyCircuitStep::Gate(Gate::S(q))); } self } @@ -382,7 +392,7 @@ impl CircuitBuilder { /// S-dagger gate. pub fn sdg(&mut self, qubits: &[usize]) -> &mut Self { for &q in qubits { - self.ops.push(CircuitOp::Sdg(q)); + self.steps.push(NoisyCircuitStep::Gate(Gate::Sdg(q))); } self } @@ -390,7 +400,7 @@ impl CircuitBuilder { /// Pauli X gate. pub fn x(&mut self, qubits: &[usize]) -> &mut Self { for &q in qubits { - self.ops.push(CircuitOp::X(q)); + self.steps.push(NoisyCircuitStep::Gate(Gate::X(q))); } self } @@ -398,7 +408,7 @@ impl CircuitBuilder { /// Pauli Y gate. pub fn y(&mut self, qubits: &[usize]) -> &mut Self { for &q in qubits { - self.ops.push(CircuitOp::Y(q)); + self.steps.push(NoisyCircuitStep::Gate(Gate::Y(q))); } self } @@ -406,7 +416,7 @@ impl CircuitBuilder { /// Pauli Z gate. pub fn z(&mut self, qubits: &[usize]) -> &mut Self { for &q in qubits { - self.ops.push(CircuitOp::Z(q)); + self.steps.push(NoisyCircuitStep::Gate(Gate::Z(q))); } self } @@ -414,7 +424,8 @@ impl CircuitBuilder { /// CNOT gate. pub fn cx(&mut self, pairs: &[(usize, usize)]) -> &mut Self { for &(control, target) in pairs { - self.ops.push(CircuitOp::Cx(control, target)); + self.steps + .push(NoisyCircuitStep::Gate(Gate::Cx(control, target))); } self } @@ -422,7 +433,7 @@ impl CircuitBuilder { /// CZ gate. pub fn cz(&mut self, pairs: &[(usize, usize)]) -> &mut Self { for &(a, b) in pairs { - self.ops.push(CircuitOp::Cz(a, b)); + self.steps.push(NoisyCircuitStep::Gate(Gate::Cz(a, b))); } self } @@ -430,7 +441,7 @@ impl CircuitBuilder { /// SWAP gate. pub fn swap(&mut self, pairs: &[(usize, usize)]) -> &mut Self { for &(a, b) in pairs { - self.ops.push(CircuitOp::Swap(a, b)); + self.steps.push(NoisyCircuitStep::Gate(Gate::Swap(a, b))); } self } @@ -438,7 +449,7 @@ impl CircuitBuilder { /// Measure qubit(s) in Z basis. pub fn mz(&mut self, qubits: &[usize]) -> &mut Self { for &q in qubits { - self.ops.push(CircuitOp::Mz(q)); + self.steps.push(NoisyCircuitStep::Gate(Gate::Mz(q))); } self } @@ -446,7 +457,8 @@ impl CircuitBuilder { /// Mark a noise injection point for single-qubit noise. pub fn noise_1q(&mut self, qubits: &[usize]) -> &mut Self { for &q in qubits { - self.ops.push(CircuitOp::Noise1Q(q)); + self.steps + .push(NoisyCircuitStep::Noise(NoiseInjection::OneQ(q))); } self } @@ -454,20 +466,21 @@ impl CircuitBuilder { /// Mark a noise injection point for two-qubit noise. pub fn noise_2q(&mut self, pairs: &[(usize, usize)]) -> &mut Self { for &(a, b) in pairs { - self.ops.push(CircuitOp::Noise2Q(a, b)); + self.steps + .push(NoisyCircuitStep::Noise(NoiseInjection::TwoQ(a, b))); } self } - /// Get the operations in this circuit. + /// Get the steps in this noisy sampler circuit. #[must_use] - pub fn ops(&self) -> &[CircuitOp] { - &self.ops + pub fn steps(&self) -> &[NoisyCircuitStep] { + &self.steps } /// Clear the circuit for reuse. pub fn clear(&mut self) { - self.ops.clear(); + self.steps.clear(); } } @@ -522,7 +535,7 @@ impl GpuNoisySampler { // Build the circuit once let mut builder = CircuitBuilder::new(); circuit_fn(&mut builder); - let ops = builder.ops().to_vec(); + let steps = builder.steps().to_vec(); let mut results = Vec::with_capacity(shots); @@ -542,36 +555,36 @@ impl GpuNoisySampler { let mut outcomes = Vec::new(); // Execute the circuit with noise injection - for op in &ops { - match op { - CircuitOp::H(q) => { + for step in &steps { + match step { + NoisyCircuitStep::Gate(Gate::H(q)) => { sim.h(&[QubitId(*q)]); } - CircuitOp::S(q) => { + NoisyCircuitStep::Gate(Gate::S(q)) => { sim.sz(&[QubitId(*q)]); } - CircuitOp::Sdg(q) => { + NoisyCircuitStep::Gate(Gate::Sdg(q)) => { sim.szdg(&[QubitId(*q)]); } - CircuitOp::X(q) => { + NoisyCircuitStep::Gate(Gate::X(q)) => { sim.x(&[QubitId(*q)]); } - CircuitOp::Y(q) => { + NoisyCircuitStep::Gate(Gate::Y(q)) => { sim.y(&[QubitId(*q)]); } - CircuitOp::Z(q) => { + NoisyCircuitStep::Gate(Gate::Z(q)) => { sim.z(&[QubitId(*q)]); } - CircuitOp::Cx(ctrl, tgt) => { + NoisyCircuitStep::Gate(Gate::Cx(ctrl, tgt)) => { sim.cx(&[(QubitId(*ctrl), QubitId(*tgt))]); } - CircuitOp::Cz(a, b) => { + NoisyCircuitStep::Gate(Gate::Cz(a, b)) => { sim.cz(&[(QubitId(*a), QubitId(*b))]); } - CircuitOp::Swap(a, b) => { + NoisyCircuitStep::Gate(Gate::Swap(a, b)) => { sim.swap(&[(QubitId(*a), QubitId(*b))]); } - CircuitOp::Mz(q) => { + NoisyCircuitStep::Gate(Gate::Mz(q)) => { let results = sim.mz(&[QubitId(*q)]); let mut outcome = results[0].outcome; @@ -585,7 +598,7 @@ impl GpuNoisySampler { outcomes.push(outcome); } - CircuitOp::Noise1Q(q) => { + NoisyCircuitStep::Noise(NoiseInjection::OneQ(q)) => { // Sample and apply single-qubit noise match self.noise_sampler.sample_1q(*q) { Pauli::I => {} @@ -600,7 +613,7 @@ impl GpuNoisySampler { } } } - CircuitOp::Noise2Q(a, b) => { + NoisyCircuitStep::Noise(NoiseInjection::TwoQ(a, b)) => { // Sample and apply two-qubit noise let (pa, pb) = self.noise_sampler.sample_2q(*a, *b); match pa { @@ -688,13 +701,31 @@ mod tests { .noise_2q(&[(0, 1)]) .mz(&[0, 1]); - assert_eq!(builder.ops().len(), 6); - assert!(matches!(builder.ops()[0], CircuitOp::H(0))); - assert!(matches!(builder.ops()[1], CircuitOp::Noise1Q(0))); - assert!(matches!(builder.ops()[2], CircuitOp::Cx(0, 1))); - assert!(matches!(builder.ops()[3], CircuitOp::Noise2Q(0, 1))); - assert!(matches!(builder.ops()[4], CircuitOp::Mz(0))); - assert!(matches!(builder.ops()[5], CircuitOp::Mz(1))); + assert_eq!(builder.steps().len(), 6); + assert!(matches!( + builder.steps()[0], + NoisyCircuitStep::Gate(Gate::H(0)) + )); + assert!(matches!( + builder.steps()[1], + NoisyCircuitStep::Noise(NoiseInjection::OneQ(0)) + )); + assert!(matches!( + builder.steps()[2], + NoisyCircuitStep::Gate(Gate::Cx(0, 1)) + )); + assert!(matches!( + builder.steps()[3], + NoisyCircuitStep::Noise(NoiseInjection::TwoQ(0, 1)) + )); + assert!(matches!( + builder.steps()[4], + NoisyCircuitStep::Gate(Gate::Mz(0)) + )); + assert!(matches!( + builder.steps()[5], + NoisyCircuitStep::Gate(Gate::Mz(1)) + )); } #[test] @@ -715,23 +746,59 @@ mod tests { .noise_2q(&[(0, 1)]) .mz(&[0]); - assert_eq!(builder.ops().len(), 12); - assert!(matches!(builder.ops()[0], CircuitOp::H(0))); - assert!(matches!(builder.ops()[1], CircuitOp::S(0))); - assert!(matches!(builder.ops()[2], CircuitOp::Sdg(0))); - assert!(matches!(builder.ops()[3], CircuitOp::X(0))); - assert!(matches!(builder.ops()[4], CircuitOp::Y(0))); - assert!(matches!(builder.ops()[5], CircuitOp::Z(0))); - assert!(matches!(builder.ops()[6], CircuitOp::Cx(0, 1))); - assert!(matches!(builder.ops()[7], CircuitOp::Cz(0, 1))); - assert!(matches!(builder.ops()[8], CircuitOp::Swap(0, 1))); - assert!(matches!(builder.ops()[9], CircuitOp::Noise1Q(0))); - assert!(matches!(builder.ops()[10], CircuitOp::Noise2Q(0, 1))); - assert!(matches!(builder.ops()[11], CircuitOp::Mz(0))); + assert_eq!(builder.steps().len(), 12); + assert!(matches!( + builder.steps()[0], + NoisyCircuitStep::Gate(Gate::H(0)) + )); + assert!(matches!( + builder.steps()[1], + NoisyCircuitStep::Gate(Gate::S(0)) + )); + assert!(matches!( + builder.steps()[2], + NoisyCircuitStep::Gate(Gate::Sdg(0)) + )); + assert!(matches!( + builder.steps()[3], + NoisyCircuitStep::Gate(Gate::X(0)) + )); + assert!(matches!( + builder.steps()[4], + NoisyCircuitStep::Gate(Gate::Y(0)) + )); + assert!(matches!( + builder.steps()[5], + NoisyCircuitStep::Gate(Gate::Z(0)) + )); + assert!(matches!( + builder.steps()[6], + NoisyCircuitStep::Gate(Gate::Cx(0, 1)) + )); + assert!(matches!( + builder.steps()[7], + NoisyCircuitStep::Gate(Gate::Cz(0, 1)) + )); + assert!(matches!( + builder.steps()[8], + NoisyCircuitStep::Gate(Gate::Swap(0, 1)) + )); + assert!(matches!( + builder.steps()[9], + NoisyCircuitStep::Noise(NoiseInjection::OneQ(0)) + )); + assert!(matches!( + builder.steps()[10], + NoisyCircuitStep::Noise(NoiseInjection::TwoQ(0, 1)) + )); + assert!(matches!( + builder.steps()[11], + NoisyCircuitStep::Gate(Gate::Mz(0)) + )); // Test clear builder.clear(); - assert_eq!(builder.ops().len(), 0); + assert_eq!(builder.steps().len(), 0); } #[test] diff --git a/crates/pecos-gpu-sims/src/gpu_pauli_prop.rs b/crates/pecos-gpu-sims/src/gpu_pauli_prop.rs index 067fb8b26..d353873b9 100644 --- a/crates/pecos-gpu-sims/src/gpu_pauli_prop.rs +++ b/crates/pecos-gpu-sims/src/gpu_pauli_prop.rs @@ -651,15 +651,15 @@ impl GpuPauliProp { /// Check if a Pauli string anticommutes with the accumulated faults. /// - /// This is used to check for logical errors: if the fault anticommutes - /// with a logical operator, it's a logical error. + /// This is used to check whether faults flip a tracked Pauli string: + /// an odd number of anticommutations means the tracked Pauli flips. /// /// # Arguments /// * `x_qubits` - Qubits with X in the Pauli string /// * `z_qubits` - Qubits with Z in the Pauli string /// /// # Returns - /// A vector of bools, one per shot, true if anticommutes (logical error). + /// A vector of bools, one per shot, true if the tracked Pauli string flips. pub fn check_anticommutation(&mut self, x_qubits: &[usize], z_qubits: &[usize]) -> Vec { self.sync(); @@ -674,7 +674,7 @@ impl GpuPauliProp { let mut anticom_count = 0u32; - // X in logical anticommutes with Z faults + // X in the tracked Pauli anticommutes with Z faults. for &q in x_qubits { let base = q * self.shot_words as usize; if (z_faults[base + word_idx] >> bit_idx) & 1 != 0 { @@ -682,7 +682,7 @@ impl GpuPauliProp { } } - // Z in logical anticommutes with X faults + // Z in the tracked Pauli anticommutes with X faults. for &q in z_qubits { let base = q * self.shot_words as usize; if (x_faults[base + word_idx] >> bit_idx) & 1 != 0 { @@ -885,11 +885,11 @@ mod tests { prop.inject_x_fault(0); prop.flush(); - // Check against Z logical on qubit 0 (should anticommute) + // Check against tracked Z on qubit 0 (should anticommute) let results = prop.check_anticommutation(&[], &[0]); assert!(results.iter().all(|&b| b)); // All shots: anticommutes - // Check against X logical on qubit 0 (should commute) + // Check against tracked X on qubit 0 (should commute) let results = prop.check_anticommutation(&[0], &[]); assert!(results.iter().all(|&b| !b)); // All shots: commutes } diff --git a/crates/pecos-gpu-sims/src/gpu_stab_multi.rs b/crates/pecos-gpu-sims/src/gpu_stab_multi.rs index 283ebf8ea..0c734a965 100644 --- a/crates/pecos-gpu-sims/src/gpu_stab_multi.rs +++ b/crates/pecos-gpu-sims/src/gpu_stab_multi.rs @@ -48,7 +48,7 @@ pub struct GpuStabMulti { #[allow(dead_code)] meas_data_buffer: wgpu::Buffer, meas_random_buffer: wgpu::Buffer, - meas_results_buffer: wgpu::Buffer, + meas_ids_buffer: wgpu::Buffer, meas_staging_buffer: wgpu::Buffer, meas_bind_group: wgpu::BindGroup, meas_find_pipeline: wgpu::ComputePipeline, @@ -228,7 +228,7 @@ impl GpuStabMulti { mapped_at_creation: false, }); - let meas_results_buffer = device.create_buffer(&wgpu::BufferDescriptor { + let meas_ids_buffer = device.create_buffer(&wgpu::BufferDescriptor { label: Some("Multi Measurement Results Buffer"), size: u64::from(shots_per_batch) * 4, usage: wgpu::BufferUsages::STORAGE @@ -506,7 +506,7 @@ impl GpuStabMulti { }, wgpu::BindGroupEntry { binding: 3, - resource: meas_results_buffer.as_entire_binding(), + resource: meas_ids_buffer.as_entire_binding(), }, ], }); @@ -602,7 +602,7 @@ impl GpuStabMulti { // GPU-side measurement meas_data_buffer, meas_random_buffer, - meas_results_buffer, + meas_ids_buffer, meas_staging_buffer, meas_bind_group, meas_find_pipeline, @@ -1427,7 +1427,7 @@ impl GpuStabMulti { // Copy this measurement's results to staging buffer encoder.copy_buffer_to_buffer( - &self.meas_results_buffer, + &self.meas_ids_buffer, 0, &self.meas_staging_buffer, 0, @@ -1576,7 +1576,7 @@ impl GpuStabMulti { } encoder.copy_buffer_to_buffer( - &self.meas_results_buffer, + &self.meas_ids_buffer, 0, &self.meas_staging_buffer, 0, diff --git a/crates/pecos-gpu-sims/src/influence_sampler_shader.wgsl b/crates/pecos-gpu-sims/src/influence_sampler_shader.wgsl index 7a290b759..1dbbc788d 100644 --- a/crates/pecos-gpu-sims/src/influence_sampler_shader.wgsl +++ b/crates/pecos-gpu-sims/src/influence_sampler_shader.wgsl @@ -16,10 +16,10 @@ struct Params { num_locations: u32, num_shots: u32, num_detectors: u32, - num_logicals: u32, + num_dem_outputs: u32, p_error_threshold: u32, // Fixed-point threshold (p * 0xFFFFFFFF) detector_words: u32, // ceil(num_detectors / 32) - logical_words: u32, // ceil(num_logicals / 32) + dem_output_words: u32, // ceil(num_dem_outputs / 32) _padding: u32, } @@ -33,22 +33,22 @@ struct Params { @group(0) @binding(5) var det_offsets_z: array; @group(0) @binding(6) var det_data_z: array; -// Logical influence CSR arrays -@group(0) @binding(7) var log_offsets_x: array; -@group(0) @binding(8) var log_data_x: array; -@group(0) @binding(9) var log_offsets_y: array; -@group(0) @binding(10) var log_data_y: array; -@group(0) @binding(11) var log_offsets_z: array; -@group(0) @binding(12) var log_data_z: array; +// DEM-output influence CSR arrays +@group(0) @binding(7) var dem_output_offsets_x: array; +@group(0) @binding(8) var dem_output_data_x: array; +@group(0) @binding(9) var dem_output_offsets_y: array; +@group(0) @binding(10) var dem_output_data_y: array; +@group(0) @binding(11) var dem_output_offsets_z: array; +@group(0) @binding(12) var dem_output_data_z: array; // Random seeds (one per shot) @group(0) @binding(13) var random_seeds: array; -// Output: detector and logical flips +// Output: detector and DEM-output flips // Layout: [shot * words + word_idx] - each shot has its own contiguous region // NO atomics needed since each shot is processed by exactly one thread @group(0) @binding(14) var detector_flips: array; -@group(0) @binding(15) var logical_flips: array; +@group(0) @binding(15) var dem_output_flips: array; // PCG-style hash function for deterministic randomness fn hash(seed: u32, loc: u32, extra: u32) -> u32 { @@ -71,12 +71,12 @@ fn xor_detector(shot_base: u32, det_idx: u32, detector_words: u32) { } } -fn xor_logical(shot_base: u32, log_idx: u32, logical_words: u32) { - let word = log_idx / 32u; - let bit = log_idx % 32u; - if (word < logical_words) { +fn xor_dem_output(shot_base: u32, dem_output_idx: u32, dem_output_words: u32) { + let word = dem_output_idx / 32u; + let bit = dem_output_idx % 32u; + if (word < dem_output_words) { let idx = shot_base + word; - logical_flips[idx] = logical_flips[idx] ^ (1u << bit); + dem_output_flips[idx] = dem_output_flips[idx] ^ (1u << bit); } } @@ -90,14 +90,14 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { let seed = random_seeds[shot]; let det_base = shot * params.detector_words; - let log_base = shot * params.logical_words; + let dem_output_base = shot * params.dem_output_words; // Initialize this shot's output to zero for (var w = 0u; w < params.detector_words; w = w + 1u) { detector_flips[det_base + w] = 0u; } - for (var w = 0u; w < params.logical_words; w = w + 1u) { - logical_flips[log_base + w] = 0u; + for (var w = 0u; w < params.dem_output_words; w = w + 1u) { + dem_output_flips[dem_output_base + w] = 0u; } // Process ALL locations for this shot @@ -113,7 +113,7 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { let rand_pauli = hash(seed, loc, 1u); let pauli = rand_pauli % 3u; - // Process detector and logical influences based on Pauli type + // Process detector and DEM-output influences based on Pauli type if (pauli == 0u) { // X fault - process detector influences let det_start = det_offsets_x[loc]; @@ -122,11 +122,11 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { xor_detector(det_base, det_data_x[i], params.detector_words); } - // X fault - process logical influences - let log_start = log_offsets_x[loc]; - let log_end = log_offsets_x[loc + 1u]; - for (var i = log_start; i < log_end; i = i + 1u) { - xor_logical(log_base, log_data_x[i], params.logical_words); + // X fault - process DEM-output influences + let dem_output_start = dem_output_offsets_x[loc]; + let dem_output_end = dem_output_offsets_x[loc + 1u]; + for (var i = dem_output_start; i < dem_output_end; i = i + 1u) { + xor_dem_output(dem_output_base, dem_output_data_x[i], params.dem_output_words); } } else if (pauli == 1u) { // Y fault - process detector influences @@ -136,11 +136,11 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { xor_detector(det_base, det_data_y[i], params.detector_words); } - // Y fault - process logical influences - let log_start = log_offsets_y[loc]; - let log_end = log_offsets_y[loc + 1u]; - for (var i = log_start; i < log_end; i = i + 1u) { - xor_logical(log_base, log_data_y[i], params.logical_words); + // Y fault - process DEM-output influences + let dem_output_start = dem_output_offsets_y[loc]; + let dem_output_end = dem_output_offsets_y[loc + 1u]; + for (var i = dem_output_start; i < dem_output_end; i = i + 1u) { + xor_dem_output(dem_output_base, dem_output_data_y[i], params.dem_output_words); } } else { // Z fault - process detector influences @@ -150,11 +150,11 @@ fn main(@builtin(global_invocation_id) global_id: vec3) { xor_detector(det_base, det_data_z[i], params.detector_words); } - // Z fault - process logical influences - let log_start = log_offsets_z[loc]; - let log_end = log_offsets_z[loc + 1u]; - for (var i = log_start; i < log_end; i = i + 1u) { - xor_logical(log_base, log_data_z[i], params.logical_words); + // Z fault - process DEM-output influences + let dem_output_start = dem_output_offsets_z[loc]; + let dem_output_end = dem_output_offsets_z[loc + 1u]; + for (var i = dem_output_start; i < dem_output_end; i = i + 1u) { + xor_dem_output(dem_output_base, dem_output_data_z[i], params.dem_output_words); } } } diff --git a/crates/pecos-gpu-sims/src/lib.rs b/crates/pecos-gpu-sims/src/lib.rs index b734d4cb3..126ac27bf 100644 --- a/crates/pecos-gpu-sims/src/lib.rs +++ b/crates/pecos-gpu-sims/src/lib.rs @@ -48,6 +48,7 @@ mod gpu_sampler; mod gpu_stab; mod gpu_stab_multi; pub mod prelude; +pub mod state_access; #[cfg(test)] mod gpu_sampler_validation; @@ -68,13 +69,14 @@ pub use gpu64::GpuStateVec64; pub type GpuStateVec = GpuStateVec64; pub use gpu_influence_sampler::{GpuInfluenceMapData, GpuInfluenceSampler, GpuSamplingResult}; pub use gpu_noisy_sampler::{ - BiasedDepolarizingNoiseSampler, CircuitBuilder, CircuitOp, DepolarizingNoiseSampler, - GpuNoisySampler, NoiseSampler, Pauli, ShotResult, + BiasedDepolarizingNoiseSampler, CircuitBuilder, DepolarizingNoiseSampler, Gate, + GpuNoisySampler, NoiseInjection, NoiseSampler, NoisyCircuitStep, Pauli, ShotResult, }; pub use gpu_pauli_prop::GpuPauliProp; pub use gpu_sampler::{GpuMeasurementSampler, GpuSampleResult}; pub use gpu_stab::GpuStab; pub use gpu_stab_multi::GpuStabMulti; +pub use state_access::{GpuDensityMatrixHostAccess, GpuStateVectorHostAccess}; /// Default GPU stabilizer simulator using `PecosRng` pub type DefaultGpuStab = GpuStab; diff --git a/crates/pecos-gpu-sims/src/state_access.rs b/crates/pecos-gpu-sims/src/state_access.rs new file mode 100644 index 000000000..95dd7f032 --- /dev/null +++ b/crates/pecos-gpu-sims/src/state_access.rs @@ -0,0 +1,177 @@ +// 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. + +//! Explicit host-snapshot state access for GPU-backed simulators. +//! +//! The generic `pecos_simulators::StateVectorAccess` and +//! `DensityMatrixAccess` traits look like ordinary passive inspection. For GPU +//! simulators, inspection requires synchronization and device-to-host copy. The +//! traits in this module make that transfer explicit in the method names while +//! returning the same little-endian data shapes as the CPU state-access traits. + +use num_complex::Complex64; +use pecos_simulators::{QuantumSimulator, StateAccessError}; + +use crate::{GpuDensityMatrix, GpuStateVec32, GpuStateVec64, GpuStateVecBackend}; + +/// Explicit host readback for GPU state-vector simulators. +pub trait GpuStateVectorHostAccess { + /// Returns the number of qubits represented by the device state. + fn num_qubits(&self) -> usize; + + /// Synchronizes pending GPU work and returns a host-owned state vector. + /// + /// The vector is in little-endian computational-basis order. Calling this + /// method performs a device-to-host transfer. + /// + /// # Errors + /// + /// Returns an error if the Hilbert-space dimension overflows. + fn state_vector_host_snapshot(&mut self) -> Result, StateAccessError>; + + /// Synchronizes pending GPU work and returns one host-copied amplitude. + /// + /// # Errors + /// + /// Returns an error if `basis_state` is outside the Hilbert space. + fn amplitude_host_snapshot( + &mut self, + basis_state: usize, + ) -> Result { + validate_basis_index(self.num_qubits(), basis_state)?; + Ok(self.state_vector_host_snapshot()?[basis_state]) + } +} + +/// Explicit host readback for GPU density-matrix simulators. +pub trait GpuDensityMatrixHostAccess { + /// Returns the number of physical qubits represented by the density matrix. + fn num_qubits(&self) -> usize; + + /// Synchronizes pending GPU work and returns a host-owned density matrix. + /// + /// The matrix is in little-endian computational-basis order. Calling this + /// method performs a device-to-host transfer and, for the current + /// Choi-state implementation, reconstructs the density matrix on the host. + /// + /// # Errors + /// + /// Returns an error if the Hilbert-space dimension overflows. + fn density_matrix_host_snapshot(&mut self) -> Result>, StateAccessError>; + + /// Synchronizes pending GPU work and returns one host-copied density-matrix + /// element. + /// + /// # Errors + /// + /// Returns an error if either basis index is outside the Hilbert space. + fn density_matrix_element_host_snapshot( + &mut self, + row: usize, + col: usize, + ) -> Result { + validate_basis_index(self.num_qubits(), row)?; + validate_basis_index(self.num_qubits(), col)?; + Ok(self.density_matrix_host_snapshot()?[row][col]) + } +} + +impl GpuStateVectorHostAccess for GpuStateVec32 { + fn num_qubits(&self) -> usize { + QuantumSimulator::num_qubits(self) + } + + fn state_vector_host_snapshot(&mut self) -> Result, StateAccessError> { + hilbert_dim(GpuStateVectorHostAccess::num_qubits(self))?; + Ok(self + .state() + .into_iter() + .map(|[re, im]| Complex64::new(f64::from(re), f64::from(im))) + .collect()) + } +} + +impl GpuStateVectorHostAccess for GpuStateVec64 { + fn num_qubits(&self) -> usize { + QuantumSimulator::num_qubits(self) + } + + fn state_vector_host_snapshot(&mut self) -> Result, StateAccessError> { + hilbert_dim(GpuStateVectorHostAccess::num_qubits(self))?; + Ok(self + .state() + .into_iter() + .map(|[re, im]| Complex64::new(re, im)) + .collect()) + } +} + +impl GpuDensityMatrixHostAccess for GpuDensityMatrix { + fn num_qubits(&self) -> usize { + self.num_qubits() + } + + fn density_matrix_host_snapshot(&mut self) -> Result>, StateAccessError> { + hilbert_dim(self.num_qubits())?; + Ok(self.get_density_matrix()) + } +} + +fn validate_basis_index(num_qubits: usize, index: usize) -> Result<(), StateAccessError> { + let dim = hilbert_dim(num_qubits)?; + if index >= dim { + return Err(StateAccessError::BasisIndexOutOfRange { + num_qubits, + dim, + index, + }); + } + Ok(()) +} + +fn hilbert_dim(num_qubits: usize) -> Result { + 2usize + .checked_pow( + num_qubits + .try_into() + .map_err(|_| StateAccessError::DimensionOverflow { num_qubits })?, + ) + .ok_or(StateAccessError::DimensionOverflow { num_qubits }) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn validates_basis_indices_against_hilbert_dimension() { + assert_eq!(validate_basis_index(3, 7), Ok(())); + assert_eq!( + validate_basis_index(3, 8), + Err(StateAccessError::BasisIndexOutOfRange { + num_qubits: 3, + dim: 8, + index: 8, + }) + ); + } + + #[test] + fn reports_dimension_overflow() { + assert_eq!(hilbert_dim(0), Ok(1)); + assert_eq!(hilbert_dim(3), Ok(8)); + assert!(matches!( + hilbert_dim(usize::BITS as usize), + Err(StateAccessError::DimensionOverflow { .. }) + )); + } +} diff --git a/crates/pecos-gpu-sims/tests/influence_sampler_audit.rs b/crates/pecos-gpu-sims/tests/influence_sampler_audit.rs index 530d5b58d..0db043ac9 100644 --- a/crates/pecos-gpu-sims/tests/influence_sampler_audit.rs +++ b/crates/pecos-gpu-sims/tests/influence_sampler_audit.rs @@ -9,59 +9,72 @@ //! //! Semantics: for each shot, each location has probability `p_error` of a //! fault. If a fault fires, a random Pauli (X/Y/Z, uniformly) is applied. -//! Each fault toggles a CSR-encoded set of detectors and logicals. +//! Each fault toggles a CSR-encoded set of detectors and DEM outputs. //! //! We don't have a CPU reference implementation to cross-check against. //! Instead, we use tight edge-case tests + distributional sanity checks. -use pecos_gpu_sims::{GpuInfluenceMapData, GpuInfluenceSampler}; +use pecos_gpu_sims::{GpuInfluenceMapData, GpuInfluenceSampler, GpuSamplingResult}; /// Build an influence map with `n_loc` locations, `n_det` detectors, and -/// `n_log` logicals, where: +/// `n_dem_outputs` DEM outputs, where: /// - X fault at location `k` toggles detector `k % n_det` -/// - Z fault at location `k` toggles logical `k % n_log` +/// - Z fault at location `k` toggles DEM output `k % n_dem_outputs` /// - Y fault at location `k` toggles both /// /// Written as three separate CSR tables (X, Y, Z each have a row per location). #[allow(clippy::cast_possible_truncation)] // CSR row offsets, n_loc bounded by test inputs (<= u32::MAX trivially) -fn simple_diagonal_map(n_loc: u32, n_det: u32, n_log: u32) -> GpuInfluenceMapData { +fn simple_diagonal_map(n_loc: u32, n_det: u32, n_dem_outputs: u32) -> GpuInfluenceMapData { let mut det_off_x = vec![0u32; (n_loc + 1) as usize]; let mut det_dat_x = Vec::::new(); let mut det_off_y = vec![0u32; (n_loc + 1) as usize]; let mut det_dat_y = Vec::::new(); let mut det_off_z = vec![0u32; (n_loc + 1) as usize]; let det_dat_z = Vec::::new(); - let mut log_off_x = vec![0u32; (n_loc + 1) as usize]; - let log_dat_x = Vec::::new(); - let mut log_off_y = vec![0u32; (n_loc + 1) as usize]; - let mut log_dat_y = Vec::::new(); - let mut log_off_z = vec![0u32; (n_loc + 1) as usize]; - let mut log_dat_z = Vec::::new(); + let mut dem_output_offsets_x = vec![0u32; (n_loc + 1) as usize]; + let dem_output_dat_x = Vec::::new(); + let mut dem_output_offsets_y = vec![0u32; (n_loc + 1) as usize]; + let mut dem_output_dat_y = Vec::::new(); + let mut dem_output_offsets_z = vec![0u32; (n_loc + 1) as usize]; + let mut dem_output_dat_z = Vec::::new(); for k in 0..n_loc { // X at k -> detector (k % n_det) det_dat_x.push(k % n_det); det_off_x[(k + 1) as usize] = det_dat_x.len() as u32; - // Z at k -> logical (k % n_log) - log_dat_z.push(k % n_log); - log_off_z[(k + 1) as usize] = log_dat_z.len() as u32; + // Z at k -> DEM output (k % n_dem_outputs) + dem_output_dat_z.push(k % n_dem_outputs); + dem_output_offsets_z[(k + 1) as usize] = dem_output_dat_z.len() as u32; // Y at k -> both det_dat_y.push(k % n_det); det_off_y[(k + 1) as usize] = det_dat_y.len() as u32; - log_dat_y.push(k % n_log); - log_off_y[(k + 1) as usize] = log_dat_y.len() as u32; + dem_output_dat_y.push(k % n_dem_outputs); + dem_output_offsets_y[(k + 1) as usize] = dem_output_dat_y.len() as u32; - // X touches no logicals (empty row) - log_off_x[(k + 1) as usize] = log_dat_x.len() as u32; + // X touches no DEM outputs (empty row) + dem_output_offsets_x[(k + 1) as usize] = dem_output_dat_x.len() as u32; // Z touches no detectors (empty row) det_off_z[(k + 1) as usize] = det_dat_z.len() as u32; } GpuInfluenceMapData::from_csr( - n_loc, n_det, n_log, det_off_x, det_dat_x, det_off_y, det_dat_y, det_off_z, det_dat_z, - log_off_x, log_dat_x, log_off_y, log_dat_y, log_off_z, log_dat_z, + n_loc, + n_det, + n_dem_outputs, + det_off_x, + det_dat_x, + det_off_y, + det_dat_y, + det_off_z, + det_dat_z, + dem_output_offsets_x, + dem_output_dat_x, + dem_output_offsets_y, + dem_output_dat_y, + dem_output_offsets_z, + dem_output_dat_z, ) } @@ -69,6 +82,40 @@ fn no_flips(flips: &[u32]) -> bool { flips.iter().all(|&w| w == 0) } +#[test] +fn logical_error_helpers_ignore_padding_bits() { + let result = GpuSamplingResult { + num_shots: 3, + detector_flips: vec![0; 3], + dem_output_flips: vec![ + 0b10, // shot 0: valid output 1 flips + 1 << 31, // shot 1: padding bit only, should be ignored + 0, // shot 2: no logical output + ], + num_detectors: 0, + num_dem_outputs: 2, + detector_words: 1, + dem_output_words: 1, + }; + + assert_eq!(result.count_logical_errors(), 1); + assert!(result.has_logical_error(0)); + assert!(!result.has_logical_error(1)); + assert!(!result.has_logical_error(2)); + + let tracked_only_result = GpuSamplingResult { + num_shots: 1, + detector_flips: vec![0], + dem_output_flips: vec![u32::MAX], + num_detectors: 0, + num_dem_outputs: 0, + detector_words: 1, + dem_output_words: 1, + }; + assert_eq!(tracked_only_result.count_logical_errors(), 0); + assert!(!tracked_only_result.has_logical_error(0)); +} + #[test] fn zero_prob_no_flips() { let map = simple_diagonal_map(32, 8, 4); @@ -109,12 +156,12 @@ fn empty_map_no_flips() { #[test] fn full_prob_saturates_parity() { // At p=1 every location fires every shot. For a map where every - // location touches at most one detector and one logical, every shot is + // location touches at most one detector and one DEM output, every shot is // an independent draw of X/Y/Z per location. The parity of the total // toggle count per detector is a deterministic function of the // per-location Pauli choices, but statistically the number of shots // that flip detector 0 should be non-zero. - let map = simple_diagonal_map(16, 1, 1); // all locations -> detector 0, logical 0 + let map = simple_diagonal_map(16, 1, 1); // all locations -> detector 0, DEM output 0 let Ok(mut sampler) = GpuInfluenceSampler::new(&map, 7) else { return; }; @@ -168,7 +215,7 @@ fn determinism_with_same_seed() { assert_eq!( ra.has_logical_error(shot), rb.has_logical_error(shot), - "shot {shot} logical mismatch" + "shot {shot} DEM-output mismatch" ); assert_eq!( ra.detector_flips_for_shot(shot), @@ -180,7 +227,7 @@ fn determinism_with_same_seed() { #[test] fn scaling_with_p_error() { - // Logical error rate should monotonically increase with p. + // logical error rate should monotonically increase with p. let map = simple_diagonal_map(32, 8, 4); let Ok(mut sampler) = GpuInfluenceSampler::new(&map, 42) else { return; diff --git a/crates/pecos-hugr/src/engine/control_flow/cfg.rs b/crates/pecos-hugr/src/engine/control_flow/cfg.rs index b05553754..b0930ee2c 100644 --- a/crates/pecos-hugr/src/engine/control_flow/cfg.rs +++ b/crates/pecos-hugr/src/engine/control_flow/cfg.rs @@ -320,10 +320,10 @@ impl HugrEngine { // Check the current block if let Some(block_info) = cfg_info.blocks.get(&active_cfg.current_block) { - // Check if this block has tracked ops that drive completion - // Quantum, calls, conditionals, bool, extension, and tailloops are tracked - // Classical_ops are not tracked (they complete when their inputs are ready) - let has_tracked_ops = !block_info.quantum_ops.is_empty() + // 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() @@ -331,7 +331,7 @@ impl HugrEngine { || !block_info.tailloop_nodes.is_empty(); // Check if the processed node is in this block - let is_in_block = if has_tracked_ops { + 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) @@ -345,8 +345,8 @@ impl HugrEngine { if is_in_block { // Check completion based on block type - let block_complete = if has_tracked_ops { - // Block with tracked ops: wait for all tracked op types + 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() diff --git a/crates/pecos-ldpc-decoders/pecos.toml b/crates/pecos-ldpc-decoders/pecos.toml index 9a2b4b7a7..55fab446c 100644 --- a/crates/pecos-ldpc-decoders/pecos.toml +++ b/crates/pecos-ldpc-decoders/pecos.toml @@ -10,13 +10,13 @@ sha256 = "6478edfe2f3305127cffe8caf73ea0176c53769f4bf1585be237eb30798c3b8e" description = "C++ Boost libraries" [dependencies.ldpc] -version = "31cf9f33872f32579af1efbe1e84552d42b03ea8" -url = "https://github.com/quantumgizmos/ldpc/archive/31cf9f33872f32579af1efbe1e84552d42b03ea8.tar.gz" -sha256 = "43ea9bfe543233c5f65e2dfb7966229df803040b4b26e25e99c3068eb23a797a" +version = "d3429964cd4ffe1abfc041c6ec8b8425cb174f40" +url = "https://github.com/quantumgizmos/ldpc/archive/d3429964cd4ffe1abfc041c6ec8b8425cb174f40.tar.gz" +sha256 = "76af0f01446ee7cbed33a47d6b597c10d8d12b2f10d508911b3d0763844d467e" description = "LDPC decoders" [dependencies.stim] -version = "bd60b73525fd5a9b30839020eb7554ad369e4337" -url = "https://github.com/quantumlib/Stim/archive/bd60b73525fd5a9b30839020eb7554ad369e4337.tar.gz" -sha256 = "2a4be24295ce3018d79e08369b31e401a2d33cd8b3a75675d57dac3afd9de37d" +version = "213275795e49027772bea7c610b6aac3a80583e1" +url = "https://github.com/quantumlib/Stim/archive/213275795e49027772bea7c610b6aac3a80583e1.tar.gz" +sha256 = "b63c4ae94494a8819d440757776cf80a829ec1e30454fa7c9b0f670e981938ab" description = "Stabilizer simulator for QEC" diff --git a/crates/pecos-ldpc-decoders/src/bridge.cpp b/crates/pecos-ldpc-decoders/src/bridge.cpp index 362249f23..473b7bd7b 100644 --- a/crates/pecos-ldpc-decoders/src/bridge.cpp +++ b/crates/pecos-ldpc-decoders/src/bridge.cpp @@ -102,7 +102,7 @@ std::unique_ptr create_bp_osd_decoder( omp_thread_count, serial_schedule_vec, random_schedule_seed, - true, // random_schedule_at_every_iteration + serial_schedule_vec.empty(), // random_serial_schedule: use random if no fixed schedule static_cast(input_vector_type) ); @@ -254,7 +254,7 @@ std::unique_ptr create_bp_lsd_decoder( omp_thread_count, serial_schedule_vec, random_schedule_seed, - true, // random_schedule_at_every_iteration + serial_schedule_vec.empty(), // random_serial_schedule: use random if no fixed schedule static_cast(input_vector_type) ); diff --git a/crates/pecos-ldpc-decoders/src/core_traits_simple.rs b/crates/pecos-ldpc-decoders/src/core_traits_simple.rs index c786919c2..7201162c1 100644 --- a/crates/pecos-ldpc-decoders/src/core_traits_simple.rs +++ b/crates/pecos-ldpc-decoders/src/core_traits_simple.rs @@ -5,7 +5,7 @@ //! parameters required by LDPC decoders. use crate::decoders::{ - BeliefFindDecoder, BpLsdDecoder, BpOsdDecoder, FlipDecoder, SoftInfoBpDecoder, + BeliefFindDecoder, BpLsdDecoder, BpOsdDecoder, FlipDecoder, SoftInfoBpDecoder, UnionFindDecoder, }; use crate::{DecodingResult, LdpcError}; use ndarray::ArrayView1; @@ -37,6 +37,10 @@ impl DecodingResultTrait for DecodingResult { self.converged } + fn correction(&self) -> &[u8] { + self.decoding.as_slice().unwrap_or(&[]) + } + fn iterations(&self) -> Option { Some(self.iterations) } @@ -88,6 +92,24 @@ impl Decoder for BpLsdDecoder { } } +/// Implement Decoder trait for `UnionFindDecoder` +impl Decoder for UnionFindDecoder { + type Result = DecodingResult; + type Error = LdpcError; + + fn decode(&mut self, input: &ArrayView1) -> Result { + self.decode(input, &[], 0) + } + + fn check_count(&self) -> usize { + self.check_count() + } + + fn bit_count(&self) -> usize { + self.bit_count() + } +} + /// Implement Decoder trait for `FlipDecoder` impl Decoder for FlipDecoder { type Result = DecodingResult; diff --git a/crates/pecos-mwpf/Cargo.toml b/crates/pecos-mwpf/Cargo.toml new file mode 100644 index 000000000..64e6302e7 --- /dev/null +++ b/crates/pecos-mwpf/Cargo.toml @@ -0,0 +1,25 @@ +[package] +name = "pecos-mwpf" +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 = "MWPF hypergraph decoder for PECOS" + +[dependencies] +pecos-decoder-core.workspace = true +ndarray.workspace = true +thiserror.workspace = true +mwpf.workspace = true +serde_json.workspace = true + +[lib] +name = "pecos_mwpf" + +[lints] +workspace = true diff --git a/crates/pecos-mwpf/README.md b/crates/pecos-mwpf/README.md new file mode 100644 index 000000000..9772ce17f --- /dev/null +++ b/crates/pecos-mwpf/README.md @@ -0,0 +1,16 @@ +# pecos-mwpf + +PECOS wrapper for the [MWPF (Minimum-Weight Parity Factor)](https://github.com/yuewuo/mwpf) hypergraph decoder by Yue Wu (Yale). + +Unlike MWPM decoders (PyMatching, Fusion Blossom), MWPF handles hyperedges natively. This means it can decode Y errors, depolarizing noise, color codes, and small QLDPC codes with higher accuracy than graph-based decoders that must decompose hyperedges. + +The tradeoff is a heavier worst-case latency tail. MWPF is best suited for offline benchmarks, correlated-noise studies, and accuracy-first decoding. + +## Key configuration + +- `cluster_node_limit` (default 50): Controls accuracy vs speed. Lower values are faster. +- `timeout`: Optional solver timeout in seconds. + +## License + +MWPF is MIT-licensed. This wrapper is Apache-2.0-licensed as part of PECOS. diff --git a/crates/pecos-mwpf/src/core_traits.rs b/crates/pecos-mwpf/src/core_traits.rs new file mode 100644 index 000000000..fe7922ccb --- /dev/null +++ b/crates/pecos-mwpf/src/core_traits.rs @@ -0,0 +1,31 @@ +// 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. + +//! Implementation of core decoder traits for MWPF + +use crate::decoder::MwpfDecoder; + +/// Implement `ObservableDecoder` for `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( + &mut self, + syndrome: &[u8], + ) -> std::result::Result { + let result = self + .decode_syndrome(syndrome) + .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; + Ok(result.observable_mask) + } +} diff --git a/crates/pecos-mwpf/src/decoder.rs b/crates/pecos-mwpf/src/decoder.rs new file mode 100644 index 000000000..244fa6edf --- /dev/null +++ b/crates/pecos-mwpf/src/decoder.rs @@ -0,0 +1,337 @@ +// 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. + +//! MWPF hypergraph decoder implementation +//! +//! Wraps the Minimum-Weight Parity Factor solver by Yue Wu (Yale). +//! Unlike MWPM decoders, MWPF handles hyperedges natively -- it does not +//! need graphlike decomposition, so it can decode Y errors, depolarizing +//! noise, color codes, and small QLDPC codes with higher accuracy. + +use crate::errors::{MwpfError, Result}; +use mwpf::mwpf_solver::{ + SolverBPWrapper, SolverBase, SolverSerialJointSingleHair, SolverSerialSingleHair, + SolverSerialUnionFind, SolverTrait, +}; +use mwpf::util::{HyperEdge, SolverInitializer, SyndromePattern}; +use pecos_decoder_core::dem::DemCheckMatrix; +use std::collections::BTreeMap; +use std::sync::Arc; + +/// Which MWPF solver variant to use. +#[derive(Debug, Clone, Copy, Default)] +pub enum MwpfSolverType { + /// Union-find only -- fastest, lowest accuracy. + UnionFind, + /// Single hair plugin pass -- moderate speed and accuracy. + SingleHair, + /// BP preprocessing + `JointSingleHair`. BP guides the solver to + /// converge faster while maintaining accuracy. + BpHybrid, + /// Joint single hair with repeated optimization -- best accuracy, slowest. + #[default] + JointSingleHair, +} + +/// Configuration for the MWPF decoder. +/// +/// MWPF has three knobs at the solver level: +/// - `cluster_node_limit`: controls optimization depth (accuracy vs speed) +/// - `timeout`: wall-clock cap, falls back to union-find on expiry +/// - `only_solve_primal_once`: skip intermediate primal solutions +#[derive(Debug, Clone, Copy)] +pub struct MwpfConfig { + /// Which solver variant to use. Default: `JointSingleHair` (best accuracy). + pub solver_type: MwpfSolverType, + + /// Maximum number of nodes per cluster during optimization. + /// Lower values are faster but less accurate. + /// Default: 50 (paper's sweet spot for d=7 circuit-level). + pub cluster_node_limit: usize, + + /// Timeout in seconds for the solver. When exceeded, the solver stops + /// optimizing and returns the best solution found so far (union-find + /// baseline). `None` means no timeout. + /// + /// Setting this is the main way to tame the p99 latency tail. + pub timeout: Option, + + /// If true, solve the primal only once at the end instead of after each + /// plugin iteration. Can be faster at the cost of missing local optima. + pub only_solve_primal_once: bool, +} + +impl Default for MwpfConfig { + fn default() -> Self { + Self { + solver_type: MwpfSolverType::default(), + cluster_node_limit: 50, + timeout: None, + only_solve_primal_once: false, + } + } +} + +impl MwpfConfig { + /// Build the `serde_json` config object for the MWPF solver. + fn to_solver_config(self) -> serde_json::Value { + let mut map = serde_json::Map::new(); + map.insert( + "cluster_node_limit".to_string(), + serde_json::Number::from(self.cluster_node_limit).into(), + ); + if let Some(t) = self.timeout + && let Some(n) = serde_json::Number::from_f64(t) + { + map.insert("timeout".to_string(), serde_json::Value::Number(n)); + } + if self.only_solve_primal_once { + map.insert("only_solve_primal_once".to_string(), true.into()); + } + serde_json::Value::Object(map) + } +} + +/// Decoding result from the MWPF decoder. +#[derive(Debug, Clone)] +pub struct MwpfDecodingResult { + /// Observable prediction as a bitmask (bit i set = observable i flipped). + pub observable_mask: u64, + /// Edge indices in the solution subgraph. + pub subgraph: Vec, +} + +/// Internal solver enum holding any MWPF solver variant. +#[allow(clippy::large_enum_variant)] // Solver structs are owned to avoid extra solver indirection. +enum Solver { + UnionFind(SolverSerialUnionFind), + SingleHair(SolverSerialSingleHair), + JointSingleHair(SolverSerialJointSingleHair), + BpHybrid(SolverBPWrapper), +} + +struct EdgeInfo { + prob: f64, + obs_mask: u64, + best_prob: f64, +} + +impl Solver { + fn solve(&mut self, syndrome: SyndromePattern) { + match self { + Self::UnionFind(s) => s.solve(syndrome), + Self::SingleHair(s) => s.solve(syndrome), + Self::JointSingleHair(s) => s.solve(syndrome), + Self::BpHybrid(s) => s.solve(syndrome), + } + } + + fn subgraph(&mut self) -> mwpf::util::OutputSubgraph { + match self { + Self::UnionFind(s) => s.subgraph(), + Self::SingleHair(s) => s.subgraph(), + Self::JointSingleHair(s) => s.subgraph(), + Self::BpHybrid(s) => s.subgraph(), + } + } + + fn clear(&mut self) { + match self { + Self::UnionFind(s) => s.clear(), + Self::SingleHair(s) => s.clear(), + Self::JointSingleHair(s) => s.clear(), + Self::BpHybrid(s) => s.clear(), + } + } +} + +/// MWPF hypergraph decoder. +/// +/// Constructed from a full (non-decomposed) DEM string. Each error mechanism +/// in the DEM becomes one hyperedge in the solver, preserving correlations +/// that MWPM decoders must decompose away. +pub struct MwpfDecoder { + /// The MWPF solver instance. + solver: Solver, + /// Per-edge observable bitmask (indexed by deduped edge index). + edge_obs: Vec, + /// Number of detectors. + num_detectors: usize, + /// Reusable buffer for defect vertices (avoids per-shot allocation). + defect_buf: Vec, +} + +impl MwpfDecoder { + /// Create a decoder from a DEM string and configuration. + /// + /// The DEM should be full (non-decomposed) to preserve hyperedges. + /// + /// # Errors + /// + /// Returns `MwpfError` if the DEM is malformed or the solver cannot be + /// constructed. + 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()))?; + + // Build hyperedges from the check matrix. Each mechanism (column) becomes + // one HyperEdge with all its incident detectors. + // Merge duplicate vertex sets and build per-edge observable masks. + // Decomposed DEMs can have multiple mechanisms with the same detector set. + // Merge by combining probabilities (independent union) and tracking the + // observable from the highest-probability mechanism (first-observable-wins). + let mut edge_map: BTreeMap, EdgeInfo> = BTreeMap::new(); + for m in 0..dem.num_mechanisms { + let p = dem.error_priors[m]; + if p <= 0.0 { + continue; + } + + let vertices: Vec = (0..dem.num_detectors) + .filter(|&d| dem.check_matrix[[d, m]] != 0) + .collect(); + + if vertices.is_empty() { + continue; + } + + // Compute this mechanism's observable mask. + let mut obs: u64 = 0; + for o in 0..dem.num_observables { + if dem.observable_matrix[[o, m]] != 0 { + obs |= 1 << o; + } + } + + let entry = edge_map.entry(vertices).or_insert(EdgeInfo { + prob: 0.0, + obs_mask: obs, + best_prob: p, + }); + let old_p = entry.prob; + entry.prob = old_p + p - old_p * p; + if p > entry.best_prob { + entry.obs_mask = obs; + entry.best_prob = p; + } + } + + let mut hyperedges = Vec::with_capacity(edge_map.len()); + let mut edge_obs = Vec::with_capacity(edge_map.len()); + for (vertices, info) in edge_map { + let weight = if info.prob < 1.0 { + ((1.0 - info.prob) / info.prob).ln() + } else { + 0.0 + }; + hyperedges.push(HyperEdge::new(vertices, weight.into())); + edge_obs.push(info.obs_mask); + } + + let initializer = Arc::new(SolverInitializer::new(dem.num_detectors, hyperedges)); + let solver_config = config.to_solver_config(); + let solver = match config.solver_type { + MwpfSolverType::UnionFind => { + Solver::UnionFind(SolverSerialUnionFind::new(&initializer, solver_config)) + } + MwpfSolverType::SingleHair => { + Solver::SingleHair(SolverSerialSingleHair::new(&initializer, solver_config)) + } + MwpfSolverType::JointSingleHair => Solver::JointSingleHair( + SolverSerialJointSingleHair::new(&initializer, solver_config), + ), + MwpfSolverType::BpHybrid => { + let base = SolverBase { + inner: mwpf::mwpf_solver::SolverEnum::SolverSerialJointSingleHair( + SolverSerialJointSingleHair::new(&initializer, solver_config), + ), + }; + // BP with 50 iterations and 0.5 application ratio (paper defaults). + Solver::BpHybrid(SolverBPWrapper::new(base, 50, 0.5)) + } + }; + + Ok(Self { + solver, + edge_obs, + num_detectors: dem.num_detectors, + defect_buf: Vec::new(), + }) + } + + /// Decode a syndrome and return the observable mask. + /// + /// The syndrome is a byte slice of length `num_detectors`, where + /// non-zero entries indicate triggered detectors. + /// + /// # Errors + /// + /// Returns `MwpfError::DecodingFailed` if decoding fails. + pub fn decode_syndrome(&mut self, syndrome: &[u8]) -> Result { + // Reuse defect buffer across shots + self.defect_buf.clear(); + for (i, &v) in syndrome.iter().enumerate() { + if v != 0 { + self.defect_buf.push(i); + } + } + + if self.defect_buf.is_empty() { + return Ok(MwpfDecodingResult { + observable_mask: 0, + subgraph: Vec::new(), + }); + } + + self.solver + .solve(SyndromePattern::new_vertices(self.defect_buf.clone())); + let output = self.solver.subgraph(); + self.solver.clear(); + + // Compute observable mask from the correction subgraph. + let mut observable_mask = 0u64; + for &edge_idx in &output.subgraph { + if edge_idx < self.edge_obs.len() { + observable_mask ^= self.edge_obs[edge_idx]; + } + } + + Ok(MwpfDecodingResult { + observable_mask, + subgraph: output.subgraph, + }) + } + + /// Number of detectors in the model. + #[must_use] + pub fn num_detectors(&self) -> usize { + self.num_detectors + } + + /// Number of edges in the model (after deduplication). + #[must_use] + pub fn num_edges(&self) -> usize { + self.edge_obs.len() + } + + /// Number of observables in the model. + #[must_use] + pub fn num_observables(&self) -> usize { + let mut max_bit = 0usize; + for &obs in &self.edge_obs { + if obs != 0 { + max_bit = max_bit.max(64 - obs.leading_zeros() as usize); + } + } + max_bit + } +} diff --git a/crates/pecos-mwpf/src/errors.rs b/crates/pecos-mwpf/src/errors.rs new file mode 100644 index 000000000..dd95cfd6b --- /dev/null +++ b/crates/pecos-mwpf/src/errors.rs @@ -0,0 +1,46 @@ +// 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. + +//! Error types for the MWPF decoder + +use thiserror::Error; + +/// Error type for MWPF operations +#[derive(Error, Debug)] +pub enum MwpfError { + /// Configuration error + #[error("Configuration error: {0}")] + Configuration(String), + + /// Decoding failed + #[error("Decoding failed: {0}")] + DecodingFailed(String), + + /// Invalid DEM format + #[error("Invalid DEM: {0}")] + InvalidDem(String), +} + +/// Result type for MWPF operations +pub type Result = std::result::Result; + +/// Convert `MwpfError` to `DecoderError` +impl From for pecos_decoder_core::DecoderError { + fn from(e: MwpfError) -> Self { + match e { + MwpfError::Configuration(msg) | MwpfError::InvalidDem(msg) => { + pecos_decoder_core::DecoderError::InvalidConfiguration(msg) + } + MwpfError::DecodingFailed(msg) => pecos_decoder_core::DecoderError::DecodingFailed(msg), + } + } +} diff --git a/crates/pecos-mwpf/src/lib.rs b/crates/pecos-mwpf/src/lib.rs new file mode 100644 index 000000000..81f301876 --- /dev/null +++ b/crates/pecos-mwpf/src/lib.rs @@ -0,0 +1,36 @@ +// 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. + +//! MWPF hypergraph decoder module +//! +//! This module provides Rust bindings for the Minimum-Weight Parity Factor +//! decoder for quantum error correction. Unlike MWPM decoders, MWPF handles +//! hyperedges natively -- it can decode Y errors, depolarizing noise, color +//! codes, and small QLDPC codes with higher accuracy than graphlike decoders. +//! +//! Tradeoff: MWPF has a heavier worst-case latency tail than MWPM. Good for +//! offline benchmarks, correlated-noise studies, and accuracy-first decoding. + +// Allow casts between float/int for weight conversions +#![allow( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_sign_loss +)] + +pub mod core_traits; +pub mod decoder; +pub mod errors; + +// Re-export main types +pub use decoder::{MwpfConfig, MwpfDecoder, MwpfDecodingResult, MwpfSolverType}; +pub use errors::MwpfError; diff --git a/crates/pecos-mwpf/tests/basic.rs b/crates/pecos-mwpf/tests/basic.rs new file mode 100644 index 000000000..6f3172cb8 --- /dev/null +++ b/crates/pecos-mwpf/tests/basic.rs @@ -0,0 +1,112 @@ +// 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. + +use pecos_decoder_core::ObservableDecoder; +use pecos_mwpf::{MwpfConfig, MwpfDecoder}; + +/// Simple repetition-code-like DEM: two detectors, two error mechanisms. +const SIMPLE_DEM: &str = "\ +error(0.1) D0 D1 L0 +error(0.1) D1 +"; + +/// A d=3 surface-code-like DEM with a hyperedge (3 detectors). +const HYPEREDGE_DEM: &str = "\ +error(0.01) D0 D1 L0 +error(0.01) D1 D2 +error(0.01) D0 D1 D2 L0 +error(0.01) D2 +"; + +#[test] +fn construct_from_simple_dem() { + let decoder = MwpfDecoder::from_dem(SIMPLE_DEM, MwpfConfig::default()); + assert!(decoder.is_ok()); + let decoder = decoder.unwrap(); + assert_eq!(decoder.num_detectors(), 2); + assert_eq!(decoder.num_observables(), 1); +} + +#[test] +fn construct_from_hyperedge_dem() { + let decoder = MwpfDecoder::from_dem(HYPEREDGE_DEM, MwpfConfig::default()); + assert!(decoder.is_ok()); + let decoder = decoder.unwrap(); + assert_eq!(decoder.num_detectors(), 3); + assert_eq!(decoder.num_observables(), 1); +} + +#[test] +fn decode_no_errors() { + let mut decoder = MwpfDecoder::from_dem(SIMPLE_DEM, MwpfConfig::default()).unwrap(); + // No detectors triggered -> no observable flips. + let syndrome = vec![0u8; 2]; + let result = decoder.decode_syndrome(&syndrome).unwrap(); + assert_eq!(result.observable_mask, 0); +} + +#[test] +fn decode_single_error() { + let mut decoder = MwpfDecoder::from_dem(SIMPLE_DEM, MwpfConfig::default()).unwrap(); + // Both D0 and D1 triggered -> mechanism 0 (D0 D1 L0), observable L0 flips. + let syndrome = vec![1u8, 1]; + let result = decoder.decode_syndrome(&syndrome).unwrap(); + assert_eq!(result.observable_mask, 1); +} + +#[test] +fn decode_boundary_error() { + let mut decoder = MwpfDecoder::from_dem(SIMPLE_DEM, MwpfConfig::default()).unwrap(); + // Only D1 triggered -> mechanism 1 (D1, boundary), no observable flip. + let syndrome = vec![0u8, 1]; + let result = decoder.decode_syndrome(&syndrome).unwrap(); + assert_eq!(result.observable_mask, 0); +} + +#[test] +fn observable_decoder_trait() { + let mut decoder = MwpfDecoder::from_dem(SIMPLE_DEM, MwpfConfig::default()).unwrap(); + let mask = decoder.decode_to_observables(&[1, 1]).unwrap(); + assert_eq!(mask, 1); +} + +#[test] +fn decode_multiple_shots() { + // Verify solver reuse works (clear() between shots). + let mut decoder = MwpfDecoder::from_dem(SIMPLE_DEM, MwpfConfig::default()).unwrap(); + for _ in 0..10 { + let _ = decoder.decode_syndrome(&[1, 1]).unwrap(); + let _ = decoder.decode_syndrome(&[0, 1]).unwrap(); + let _ = decoder.decode_syndrome(&[0, 0]).unwrap(); + } +} + +#[test] +fn custom_config() { + let config = MwpfConfig { + cluster_node_limit: 10, + timeout: Some(5.0), + ..MwpfConfig::default() + }; + let mut decoder = MwpfDecoder::from_dem(SIMPLE_DEM, config).unwrap(); + let result = decoder.decode_syndrome(&[1, 1]).unwrap(); + assert_eq!(result.observable_mask, 1); +} + +#[test] +fn hyperedge_decode() { + let mut decoder = MwpfDecoder::from_dem(HYPEREDGE_DEM, MwpfConfig::default()).unwrap(); + // All three detectors triggered: the hyperedge mechanism (D0 D1 D2 L0) + // is the minimum weight explanation. + let result = decoder.decode_syndrome(&[1, 1, 1]).unwrap(); + assert_eq!(result.observable_mask, 1); +} diff --git a/crates/pecos-num/src/array.rs b/crates/pecos-num/src/array.rs index 31f7430e6..acd6615e1 100644 --- a/crates/pecos-num/src/array.rs +++ b/crates/pecos-num/src/array.rs @@ -358,6 +358,46 @@ pub fn linspace(start: f64, stop: f64, num: usize, endpoint: bool) -> Array1 Array1 { + assert!( + start > 0.0, + "geomspace: start must be positive, got {start}" + ); + assert!(stop > 0.0, "geomspace: stop must be positive, got {stop}"); + + let log_start = start.ln(); + let log_stop = stop.ln(); + let log_values = linspace(log_start, log_stop, num, endpoint); + log_values.mapv(f64::exp) +} + // Note: sum() for slices removed - use values.iter().sum() directly (idiomatic Rust) // sum_axis() below is kept for multi-dimensional operations diff --git a/crates/pecos-num/src/lib.rs b/crates/pecos-num/src/lib.rs index 180c78c92..e95d1dbd3 100644 --- a/crates/pecos-num/src/lib.rs +++ b/crates/pecos-num/src/lib.rs @@ -46,6 +46,7 @@ pub mod polynomial; pub mod prelude; pub mod random; pub mod stats; +pub mod z2_linalg; pub use compare::{allclose, relative_eq}; pub use curve_fit::{CurveFitError, CurveFitOptions, CurveFitResult, curve_fit}; diff --git a/crates/pecos-num/src/prelude.rs b/crates/pecos-num/src/prelude.rs index 5ea6129d5..b8e0044bb 100644 --- a/crates/pecos-num/src/prelude.rs +++ b/crates/pecos-num/src/prelude.rs @@ -99,8 +99,8 @@ pub use num_complex::{Complex, Complex32, Complex64}; // Re-export array operations // Note: sum() for slices removed - use .iter().sum() directly (idiomatic Rust) pub use crate::array::{ - arange, broadcast_shapes, broadcast_to, delete, diag, diag_matrix, linspace, ones, sum_axis, - zeros, + arange, broadcast_shapes, broadcast_to, delete, diag, diag_matrix, geomspace, linspace, ones, + sum_axis, zeros, }; // Re-export graph types and algorithms diff --git a/crates/pecos-num/src/z2_linalg.rs b/crates/pecos-num/src/z2_linalg.rs new file mode 100644 index 000000000..7c91088f4 --- /dev/null +++ b/crates/pecos-num/src/z2_linalg.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. + +//! Sparse linear algebra over `Z_2` (GF(2)). +//! +//! Operations on binary vectors and matrices represented as sorted index +//! sets. This is the natural representation for QEC detector definitions +//! where each detector is an XOR (sum mod 2) of a small number of +//! measurements. +//! +//! # Representation +//! +//! A `Z_2` vector is a sorted `Vec` of indices where the vector has +//! value 1. Addition (XOR) is computed as sorted-merge symmetric difference. +//! A `Z_2` matrix is a `Vec>` of row vectors. +//! +//! # Example +//! +//! ``` +//! use pecos_num::z2_linalg::{z2_rank, z2_xor}; +//! +//! // Three detectors: D0=[0], D1=[1], D2=[0,1] +//! // D2 = D0 + D1 → rank should be 2 +//! let rows = vec![vec![0], vec![1], vec![0, 1]]; +//! assert_eq!(z2_rank(&rows), 2); +//! +//! // XOR of two sparse vectors +//! assert_eq!(z2_xor(&[1, 3, 5], &[2, 3, 4]), vec![1, 2, 4, 5]); +//! ``` + +use std::collections::BTreeMap; + +/// Compute the rank of a binary matrix over `Z_2`. +/// +/// Each row is a sorted list of column indices where the row has value 1. +/// Uses sparse Gaussian elimination with leftmost-column pivoting. +/// +/// Complexity: O(n * k * log(n)) where n is the number of rows and k is +/// the average number of nonzeros per row. For QEC detectors with k ≈ 2, +/// this is effectively O(n * log(n)). +/// +/// # Arguments +/// +/// * `rows` - Binary matrix as a slice of sorted index vectors. +/// +/// # Example +/// +/// ``` +/// use pecos_num::z2_linalg::z2_rank; +/// +/// // Two independent rows +/// assert_eq!(z2_rank(&[vec![0], vec![1]]), 2); +/// +/// // Three rows, one dependent (D2 = D0 + D1) +/// assert_eq!(z2_rank(&[vec![0, 1], vec![1, 2], vec![0, 2]]), 2); +/// ``` +#[must_use] +pub fn z2_rank(rows: &[Vec]) -> usize { + let mut work: Vec> = rows.to_vec(); + let mut pivot_rows: BTreeMap = BTreeMap::new(); + let mut rank = 0; + + for i in 0..work.len() { + // Reduce row i by XOR-ing with existing pivot rows + loop { + if work[i].is_empty() { + break; + } + let min_col = work[i][0]; + if let Some(&pr) = pivot_rows.get(&min_col) { + let pivot = work[pr].clone(); + work[i] = z2_xor(&work[i], &pivot); + } else { + break; + } + } + + if !work[i].is_empty() { + let min_col = work[i][0]; + pivot_rows.insert(min_col, i); + rank += 1; + } + } + + rank +} + +/// Compute the rank of detector definitions given as record offsets. +/// +/// Each record is a list of measurement indices (possibly negative for +/// Stim-style offsets from the end). Negative offsets are resolved against +/// `num_measurements`. +/// +/// # Arguments +/// +/// * `records` - Detector definitions as record offset lists. +/// * `num_measurements` - Total number of measurements (for resolving +/// negative offsets). +#[must_use] +pub fn z2_rank_from_records(records: &[Vec], num_measurements: usize) -> usize { + let rows: Vec> = records + .iter() + .map(|record| { + let mut indices: Vec = record + .iter() + .filter_map(|&offset| { + let abs = if offset < 0 { + num_measurements.checked_sub(offset.unsigned_abs() as usize)? + } else { + offset.unsigned_abs() as usize + }; + (abs < num_measurements).then_some(abs) + }) + .collect(); + indices.sort_unstable(); + indices.dedup(); + indices + }) + .collect(); + + z2_rank(&rows) +} + +/// XOR (symmetric difference) of two sorted `Z_2` vectors. +/// +/// Both inputs must be sorted and deduplicated. The result is also sorted +/// and deduplicated. +/// +/// # Example +/// +/// ``` +/// use pecos_num::z2_linalg::z2_xor; +/// +/// assert_eq!(z2_xor(&[1, 3, 5], &[2, 3, 4]), vec![1, 2, 4, 5]); +/// assert_eq!(z2_xor(&[0, 1], &[0, 1]), Vec::::new()); +/// assert_eq!(z2_xor(&[], &[1, 2, 3]), vec![1, 2, 3]); +/// ``` +#[must_use] +pub fn z2_xor(a: &[usize], b: &[usize]) -> Vec { + let mut result = Vec::with_capacity(a.len() + b.len()); + let (mut i, mut j) = (0, 0); + + while i < a.len() && j < b.len() { + match a[i].cmp(&b[j]) { + std::cmp::Ordering::Less => { + result.push(a[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + result.push(b[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + i += 1; + j += 1; + } + } + } + + result.extend_from_slice(&a[i..]); + result.extend_from_slice(&b[j..]); + result +} + +/// Check if a set of `Z_2` row vectors are linearly independent. +/// +/// # Example +/// +/// ``` +/// use pecos_num::z2_linalg::z2_are_independent; +/// +/// assert!(z2_are_independent(&[vec![0], vec![1]])); +/// assert!(!z2_are_independent(&[vec![0], vec![1], vec![0, 1]])); +/// ``` +#[must_use] +pub fn z2_are_independent(rows: &[Vec]) -> bool { + z2_rank(rows) == rows.len() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn rank_empty() { + assert_eq!(z2_rank(&[]), 0); + } + + #[test] + fn rank_single() { + assert_eq!(z2_rank(&[vec![0]]), 1); + } + + #[test] + fn rank_independent() { + assert_eq!(z2_rank(&[vec![0], vec![1], vec![2]]), 3); + } + + #[test] + fn rank_dependent() { + assert_eq!(z2_rank(&[vec![0], vec![1], vec![0, 1]]), 2); + } + + #[test] + fn rank_all_identical() { + assert_eq!(z2_rank(&[vec![0, 1], vec![0, 1], vec![0, 1]]), 1); + } + + #[test] + fn rank_chain_dependent() { + // D0=[0,1], D1=[1,2], D2=[0,2] → D2 = D0 + D1 → rank 2 + assert_eq!(z2_rank(&[vec![0, 1], vec![1, 2], vec![0, 2]]), 2); + } + + #[test] + fn rank_large_sparse() { + let rows: Vec> = (0..1000).map(|i| vec![i]).collect(); + assert_eq!(z2_rank(&rows), 1000); + } + + #[test] + fn rank_from_records_negative_offsets() { + let records = vec![vec![-1i32], vec![-2]]; + assert_eq!(z2_rank_from_records(&records, 10), 2); + } + + #[test] + fn rank_from_records_dependent() { + let records = vec![vec![0i32], vec![1], vec![0, 1]]; + assert_eq!(z2_rank_from_records(&records, 10), 2); + } + + #[test] + fn xor_basic() { + assert_eq!(z2_xor(&[1, 3, 5], &[2, 3, 4]), vec![1, 2, 4, 5]); + } + + #[test] + fn xor_cancel() { + assert_eq!(z2_xor(&[1, 2], &[1, 2]), Vec::::new()); + } + + #[test] + fn xor_empty() { + assert_eq!(z2_xor(&[], &[1, 2]), vec![1, 2]); + assert_eq!(z2_xor(&[1, 2], &[]), vec![1, 2]); + } + + #[test] + fn independence_check() { + assert!(z2_are_independent(&[vec![0], vec![1]])); + assert!(!z2_are_independent(&[vec![0], vec![1], vec![0, 1]])); + assert!(z2_are_independent(&[])); + } +} diff --git a/crates/pecos-phir-json/src/v0_1/ast.rs b/crates/pecos-phir-json/src/v0_1/ast.rs index 4f565dfc9..2a81b0aff 100644 --- a/crates/pecos-phir-json/src/v0_1/ast.rs +++ b/crates/pecos-phir-json/src/v0_1/ast.rs @@ -1,4 +1,4 @@ -use serde::{Deserialize, Deserializer}; +use serde::Deserialize; use std::collections::BTreeMap; use std::f64::consts::PI; @@ -12,9 +12,13 @@ pub struct PHIRProgram { pub ops: Vec, } -/// Represents an operation in the PHIR program -#[derive(Debug, Deserialize, Clone)] -#[serde(untagged)] +/// Represents an operation in the PHIR program. +/// +/// Deserialized via a manual `Deserialize` impl that inspects which discriminating +/// key is present (`qop`, `cop`, `block`, `mop`, `meta`, `data`, `//`). +/// This avoids `#[serde(untagged)]` whose `ContentDeserializer` can silently fail +/// with certain nested types (serde issue 1183). +#[derive(Debug, Clone)] pub enum Operation { /// Variable definition for quantum or classical variables VariableDefinition { @@ -22,63 +26,45 @@ pub enum Operation { data_type: String, variable: String, /// Size in bits. Optional -- if omitted, inferred from `data_type`. - #[serde(default)] size: Option, }, /// Quantum operation (gates, measurements) QuantumOp { qop: String, - #[serde(default)] - #[serde(deserialize_with = "deserialize_angles_to_radians")] - angles: Option>, // Now just Vec in radians, no unit string + /// Angles in radians (converted from the JSON `[[values...], "unit"]` format) + angles: Option>, args: Vec, - #[serde(default)] returns: Vec<(String, usize)>, - #[serde(default)] metadata: Option>, }, /// Classical operation (e.g., Result for exporting values) ClassicalOp { cop: String, - #[serde(default)] args: Vec, - #[serde(default)] returns: Vec, - #[serde(default)] metadata: Option>, - #[serde(default, skip_serializing_if = "Option::is_none")] - function: Option, // For ffcall + function: Option, }, /// Block operation (e.g., sequence, qparallel, if) Block { block: String, - #[serde(default)] ops: Vec, - #[serde(default)] condition: Option, - #[serde(default)] true_branch: Option>, - #[serde(default)] false_branch: Option>, - #[serde(default)] metadata: Option>, }, /// Machine operation (e.g., Idle, Transport) MachineOp { mop: String, - #[serde(default)] args: Option>, - #[serde(default)] duration: Option<(f64, String)>, - #[serde(default)] metadata: Option>, }, /// Meta instruction (e.g., barrier) MetaInstruction { meta: String, - #[serde(default)] args: Vec<(String, usize)>, - #[serde(default)] metadata: Option>, }, /// Data export (`cvar_export`) -- specifies which variables to export @@ -87,10 +73,249 @@ pub enum Operation { variables: Vec, }, /// Comment - Comment { - #[serde(rename = "//")] - comment: String, - }, + Comment { comment: String }, +} + +// --------------------------------------------------------------------------- +// Manual Deserialize for Operation -- key-based dispatch on serde_json::Value +// --------------------------------------------------------------------------- + +/// Convert raw JSON angles `[[values...], "unit"]` to radians. +fn convert_angles(raw: &serde_json::Value) -> Result>, String> { + if raw.is_null() { + return Ok(None); + } + let arr = raw.as_array().ok_or("angles: expected array")?; + if arr.len() != 2 { + return Err(format!( + "angles: expected [values, unit], got {} elements", + arr.len() + )); + } + let values = arr[0] + .as_array() + .ok_or("angles: first element must be an array of numbers")? + .iter() + .map(|v| { + v.as_f64() + .ok_or_else(|| format!("angles: expected number, got {v}")) + }) + .collect::, _>>()?; + let unit = arr[1] + .as_str() + .ok_or("angles: second element must be a string")?; + match unit { + "rad" => Ok(Some(values)), + "deg" => Ok(Some(values.into_iter().map(|v| v * PI / 180.0).collect())), + "pi" => Ok(Some(values.into_iter().map(|v| v * PI).collect())), + _ => Err(format!("Unsupported angle unit: {unit}")), + } +} + +/// Helper: extract optional metadata from a JSON object. +fn extract_metadata( + obj: &serde_json::Map, +) -> Option> { + obj.get("metadata").and_then(|v| { + if v.is_null() { + None + } else { + serde_json::from_value(v.clone()).ok() + } + }) +} + +impl<'de> Deserialize<'de> for Operation { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + use serde::de::Error; + + let val = serde_json::Value::deserialize(deserializer)?; + let obj = val + .as_object() + .ok_or_else(|| D::Error::custom("operation must be a JSON object"))?; + + // Dispatch on the discriminating key + if let Some(qop_val) = obj.get("qop") { + // QuantumOp + let qop = qop_val + .as_str() + .ok_or_else(|| D::Error::custom("qop must be a string"))? + .to_string(); + let angles = obj + .get("angles") + .map_or(Ok(None), convert_angles) + .map_err(D::Error::custom)?; + let args: Vec = obj + .get("args") + .map_or(Ok(vec![]), |v| serde_json::from_value(v.clone())) + .map_err(|e| D::Error::custom(format!("args: {e}")))?; + let returns: Vec<(String, usize)> = obj + .get("returns") + .map_or(Ok(vec![]), |v| serde_json::from_value(v.clone())) + .map_err(|e| D::Error::custom(format!("returns: {e}")))?; + let metadata = extract_metadata(obj); + Ok(Operation::QuantumOp { + qop, + angles, + args, + returns, + metadata, + }) + } else if let Some(cop_val) = obj.get("cop") { + // ClassicalOp + let cop = cop_val + .as_str() + .ok_or_else(|| D::Error::custom("cop must be a string"))? + .to_string(); + let args: Vec = obj + .get("args") + .map_or(Ok(vec![]), |v| serde_json::from_value(v.clone())) + .map_err(|e| D::Error::custom(format!("args: {e}")))?; + let returns: Vec = obj + .get("returns") + .map_or(Ok(vec![]), |v| serde_json::from_value(v.clone())) + .map_err(|e| D::Error::custom(format!("returns: {e}")))?; + let metadata = extract_metadata(obj); + let function: Option = obj + .get("function") + .and_then(|v| v.as_str().map(String::from)); + Ok(Operation::ClassicalOp { + cop, + args, + returns, + metadata, + function, + }) + } else if let Some(block_val) = obj.get("block") { + // Block + let block = block_val + .as_str() + .ok_or_else(|| D::Error::custom("block must be a string"))? + .to_string(); + let ops: Vec = obj + .get("ops") + .map_or(Ok(vec![]), |v| serde_json::from_value(v.clone())) + .map_err(|e| D::Error::custom(format!("ops: {e}")))?; + let condition: Option = obj + .get("condition") + .filter(|v| !v.is_null()) + .map(|v| serde_json::from_value(v.clone())) + .transpose() + .map_err(|e| D::Error::custom(format!("condition: {e}")))?; + let true_branch: Option> = obj + .get("true_branch") + .filter(|v| !v.is_null()) + .map(|v| serde_json::from_value(v.clone())) + .transpose() + .map_err(|e| D::Error::custom(format!("true_branch: {e}")))?; + let false_branch: Option> = obj + .get("false_branch") + .filter(|v| !v.is_null()) + .map(|v| serde_json::from_value(v.clone())) + .transpose() + .map_err(|e| D::Error::custom(format!("false_branch: {e}")))?; + let metadata = extract_metadata(obj); + Ok(Operation::Block { + block, + ops, + condition, + true_branch, + false_branch, + metadata, + }) + } else if let Some(mop_val) = obj.get("mop") { + // MachineOp + let mop = mop_val + .as_str() + .ok_or_else(|| D::Error::custom("mop must be a string"))? + .to_string(); + let args: Option> = obj.get("args").and_then(|v| { + if v.is_null() { + None + } else { + serde_json::from_value(v.clone()).ok() + } + }); + let duration: Option<(f64, String)> = obj.get("duration").and_then(|v| { + if v.is_null() { + None + } else { + serde_json::from_value(v.clone()).ok() + } + }); + let metadata = extract_metadata(obj); + Ok(Operation::MachineOp { + mop, + args, + duration, + metadata, + }) + } else if let Some(meta_val) = obj.get("meta") { + // MetaInstruction + let meta = meta_val + .as_str() + .ok_or_else(|| D::Error::custom("meta must be a string"))? + .to_string(); + let args: Vec<(String, usize)> = obj + .get("args") + .map_or(Ok(vec![]), |v| serde_json::from_value(v.clone())) + .map_err(|e| D::Error::custom(format!("args: {e}")))?; + let metadata = extract_metadata(obj); + Ok(Operation::MetaInstruction { + meta, + args, + metadata, + }) + } else if let Some(comment_val) = obj.get("//") { + // Comment + let comment = comment_val + .as_str() + .ok_or_else(|| D::Error::custom("comment must be a string"))? + .to_string(); + Ok(Operation::Comment { comment }) + } else if let Some(data_val) = obj.get("data") { + let data = data_val + .as_str() + .ok_or_else(|| D::Error::custom("data must be a string"))? + .to_string(); + if obj.contains_key("variables") { + // DataExport + let variables: Vec = serde_json::from_value(obj["variables"].clone()) + .map_err(|e| D::Error::custom(format!("variables: {e}")))?; + Ok(Operation::DataExport { data, variables }) + } else { + // VariableDefinition + let data_type: String = obj + .get("data_type") + .and_then(|v| v.as_str()) + .ok_or_else(|| D::Error::custom("missing data_type"))? + .to_string(); + let variable: String = obj + .get("variable") + .and_then(|v| v.as_str()) + .ok_or_else(|| D::Error::custom("missing variable"))? + .to_string(); + let size: Option = obj + .get("size") + .and_then(serde_json::Value::as_u64) + .and_then(|n| usize::try_from(n).ok()); + Ok(Operation::VariableDefinition { + data, + data_type, + variable, + size, + }) + } + } else { + Err(D::Error::custom(format!( + "unknown operation: no recognized key (qop, cop, block, mop, meta, //, data) found in {:?}", + obj.keys().collect::>() + ))) + } + } } /// Represents an argument to a quantum operation @@ -148,25 +373,133 @@ pub fn infer_size(data_type: &str, explicit_size: Option) -> usize { digits.parse().unwrap_or(0) } -/// Custom deserializer to convert angles to radians -fn deserialize_angles_to_radians<'de, D>(deserializer: D) -> Result>, D::Error> -where - D: Deserializer<'de>, -{ - // First, deserialize as Option<(Vec, String)> - Option::<(Vec, String)>::deserialize(deserializer)?.map_or(Ok(None), |(values, unit)| { - // Convert to radians based on unit - let converted_values = match unit.as_str() { - "rad" => values, // Already in radians - "deg" => values.into_iter().map(|v| v * PI / 180.0).collect(), - "pi" => values.into_iter().map(|v| v * PI).collect(), - _ => { - return Err(serde::de::Error::custom(format!( - "Unsupported angle unit: {unit}" - ))); +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_parse_qparallel_with_angles() { + let json = r#"{ + "format": "PHIR/JSON", + "version": "0.1.0", + "metadata": {}, + "ops": [ + {"data": "qvar_define", "data_type": "qubits", "variable": "q", "size": 2}, + {"data": "cvar_define", "data_type": "u32", "variable": "m", "size": 2}, + {"qop": "RZ", "angles": [[1.0], "pi"], "args": [["q", 0], ["q", 1]]}, + {"block": "qparallel", "ops": [ + {"qop": "R1XY", "angles": [[0.5, 0.5], "pi"], "args": [["q", 0]]}, + {"qop": "R1XY", "angles": [[1.5, 0.5], "pi"], "args": [["q", 1]]} + ]}, + {"qop": "RZZ", "angles": [[0.5], "pi"], "args": [[["q", 0], ["q", 1]]]}, + {"qop": "Measure", "args": [["q", 0], ["q", 1]], "returns": [["m", 0], ["m", 1]]} + ] + }"#; + let program: PHIRProgram = serde_json::from_str(json).expect("should parse"); + assert_eq!(program.ops.len(), 6); + + // Verify angles were converted to radians (pi units) + if let Operation::QuantumOp { angles, .. } = &program.ops[2] { + let a = angles.as_ref().unwrap(); + assert!((a[0] - PI).abs() < 1e-10, "RZ angle should be pi"); + } else { + panic!("Expected QuantumOp"); + } + + // Verify inner ops of qparallel block + if let Operation::Block { ops, .. } = &program.ops[3] { + assert_eq!(ops.len(), 2); + if let Operation::QuantumOp { qop, angles, .. } = &ops[0] { + assert_eq!(qop, "R1XY"); + let a = angles.as_ref().unwrap(); + assert_eq!(a.len(), 2); + assert!((a[0] - 0.5 * PI).abs() < 1e-10); + assert!((a[1] - 0.5 * PI).abs() < 1e-10); + } else { + panic!("Expected QuantumOp inside block"); } - }; + } else { + panic!("Expected Block"); + } + } - Ok(Some(converted_values)) - }) + #[test] + fn test_parse_bell_qparallel_compact() { + // Simulate what Python json.dumps produces (compact, single-line) + let json = r#"{"format": "PHIR/JSON", "version": "0.1.0", "metadata": {"source": "pytket-phir v0.2.0", "strict_parallelism": "true"}, "ops": [{"data": "qvar_define", "data_type": "qubits", "variable": "q", "size": 2}, {"data": "cvar_define", "data_type": "u32", "variable": "m", "size": 2}, {"qop": "RZ", "angles": [[1.0], "pi"], "args": [["q", 0], ["q", 1]]}, {"block": "qparallel", "ops": [{"qop": "R1XY", "angles": [[0.5, 0.5], "pi"], "args": [["q", 0]]}, {"qop": "R1XY", "angles": [[1.5, 0.5], "pi"], "args": [["q", 1]]}]}, {"qop": "RZ", "angles": [[1.0], "pi"], "args": [["q", 0]]}, {"qop": "RZZ", "angles": [[0.5], "pi"], "args": [[["q", 0], ["q", 1]]]}, {"block": "qparallel", "ops": [{"qop": "RZ", "angles": [[1.5], "pi"], "args": [["q", 0]]}, {"qop": "RZ", "angles": [[0.5], "pi"], "args": [["q", 1]]}]}, {"qop": "R1XY", "angles": [[1.5, 0.5], "pi"], "args": [["q", 1]]}, {"qop": "Measure", "args": [["q", 0], ["q", 1]], "returns": [["m", 0], ["m", 1]]}]}"#; + let program: PHIRProgram = serde_json::from_str(json).expect("should parse"); + assert_eq!(program.ops.len(), 9); + } + + #[test] + fn test_angle_units() { + // Test all three angle unit types + let json = r#"{ + "format": "PHIR/JSON", "version": "0.1.0", "metadata": {}, + "ops": [ + {"data": "qvar_define", "data_type": "qubits", "variable": "q", "size": 1}, + {"qop": "RZ", "angles": [[1.5707963267948966], "rad"], "args": [["q", 0]]}, + {"qop": "RZ", "angles": [[90.0], "deg"], "args": [["q", 0]]}, + {"qop": "RZ", "angles": [[0.5], "pi"], "args": [["q", 0]]} + ] + }"#; + let program: PHIRProgram = serde_json::from_str(json).expect("should parse"); + + // All three should produce the same angle (pi/2) + for i in 1..=3 { + if let Operation::QuantumOp { angles, .. } = &program.ops[i] { + let a = angles.as_ref().unwrap(); + assert!( + (a[0] - std::f64::consts::FRAC_PI_2).abs() < 1e-10, + "op {i}: expected pi/2, got {}", + a[0] + ); + } + } + } + + #[test] + fn test_no_angles() { + let json = r#"{ + "format": "PHIR/JSON", "version": "0.1.0", "metadata": {}, + "ops": [ + {"data": "qvar_define", "data_type": "qubits", "variable": "q", "size": 1}, + {"qop": "H", "args": [["q", 0]]}, + {"qop": "Measure", "args": [["q", 0]], "returns": [["m", 0]]} + ] + }"#; + let program: PHIRProgram = serde_json::from_str(json).expect("should parse"); + if let Operation::QuantumOp { angles, .. } = &program.ops[1] { + assert!(angles.is_none()); + } + } + + #[test] + fn test_comment() { + let json = r#"{ + "format": "PHIR/JSON", "version": "0.1.0", "metadata": {}, + "ops": [{"//": "this is a comment"}] + }"#; + let program: PHIRProgram = serde_json::from_str(json).expect("should parse"); + if let Operation::Comment { comment } = &program.ops[0] { + assert_eq!(comment, "this is a comment"); + } else { + panic!("Expected Comment"); + } + } + + #[test] + fn test_data_export() { + let json = r#"{ + "format": "PHIR/JSON", "version": "0.1.0", "metadata": {}, + "ops": [{"data": "cvar_export", "variables": ["m", "n"]}] + }"#; + let program: PHIRProgram = serde_json::from_str(json).expect("should parse"); + if let Operation::DataExport { data, variables } = &program.ops[0] { + assert_eq!(data, "cvar_export"); + assert_eq!(variables, &["m", "n"]); + } else { + panic!("Expected DataExport"); + } + } } diff --git a/crates/pecos-phir/src/hugr_parser.rs b/crates/pecos-phir/src/hugr_parser.rs index eb91fb6ce..f4521fba9 100644 --- a/crates/pecos-phir/src/hugr_parser.rs +++ b/crates/pecos-phir/src/hugr_parser.rs @@ -517,16 +517,16 @@ impl HugrToPhirConverter { } // Step 1: Emit all Measure instructions - let mut meas_results: Vec<(SSAValue, usize)> = Vec::with_capacity(measurements.len()); + let mut meas_ids: Vec<(SSAValue, usize)> = Vec::with_capacity(measurements.len()); for &(qubit_ssa, bit_idx) in measurements { - let meas_result = self.fresh_ssa(); + let meas_id = self.fresh_ssa(); block.add_instruction(Instruction::new( Operation::Quantum(QuantumOp::Measure), vec![qubit_ssa], - vec![meas_result], + vec![meas_id], vec![Type::Bit], )); - meas_results.push((meas_result, bit_idx)); + meas_ids.push((meas_id, bit_idx)); } // Step 2: Combine bits into a single integer and emit Result @@ -541,7 +541,7 @@ impl HugrToPhirConverter { let mut accum = zero_ssa; - for &(meas_ssa, bit_idx) in &meas_results { + for &(meas_ssa, bit_idx) in &meas_ids { // Bitcast measurement bit to i64 let cast_ssa = self.fresh_ssa(); block.add_instruction(Instruction::new( diff --git a/crates/pecos-pymatching/Cargo.toml b/crates/pecos-pymatching/Cargo.toml index 8d88bc287..bbabfe235 100644 --- a/crates/pecos-pymatching/Cargo.toml +++ b/crates/pecos-pymatching/Cargo.toml @@ -10,6 +10,7 @@ license.workspace = true keywords.workspace = true categories.workspace = true description = "PyMatching MWPM decoder for PECOS" +links = "pymatching-pecos" [dependencies] pecos-decoder-core.workspace = true diff --git a/crates/pecos-pymatching/build_pymatching.rs b/crates/pecos-pymatching/build_pymatching.rs index 7dc42c4e2..640da3024 100644 --- a/crates/pecos-pymatching/build_pymatching.rs +++ b/crates/pecos-pymatching/build_pymatching.rs @@ -60,6 +60,16 @@ pub fn build() -> Result<()> { let pymatching_dir = ensure_dep_ready("pymatching", &manifest)?; let stim_dir = ensure_dep_ready("stim", &manifest)?; + // Export paths so downstream crates (e.g., pecos-chromobius) can include + // PyMatching/Stim headers and link against our compiled objects without + // compiling their own copy. + let pymatching_src = pymatching_dir.join("src"); + let stim_src = stim_dir.join("src"); + println!("cargo:pymatching_include={}", pymatching_src.display()); + println!("cargo:stim_include={}", stim_src.display()); + println!("cargo:stim_dir={}", stim_dir.display()); + println!("cargo:lib_dir={}", out_dir.display()); + // Build using cxx build_cxx_bridge(&pymatching_dir, &stim_dir)?; @@ -180,7 +190,6 @@ fn collect_pymatching_sources(pymatching_src_dir: &Path) -> Result> sources.extend([ driver_dir.join("user_graph.cc"), driver_dir.join("mwpm_decoding.cc"), - driver_dir.join("io.cc"), ]); // Matcher files diff --git a/crates/pecos-pymatching/include/pymatching_bridge.h b/crates/pecos-pymatching/include/pymatching_bridge.h index a72d10602..a76c20163 100644 --- a/crates/pecos-pymatching/include/pymatching_bridge.h +++ b/crates/pecos-pymatching/include/pymatching_bridge.h @@ -20,7 +20,8 @@ class PyMatchingGraph { // Constructors PyMatchingGraph(size_t num_nodes); PyMatchingGraph(size_t num_nodes, size_t num_observables); - static std::unique_ptr from_dem(const std::string& dem_string); + static std::unique_ptr from_dem( + const std::string& dem_string, bool enable_correlations = false); ~PyMatchingGraph(); // Edge management @@ -104,6 +105,8 @@ std::unique_ptr create_pymatching_graph_with_observables( size_t num_nodes, size_t num_observables); std::unique_ptr create_pymatching_graph_from_dem( const rust::Str dem_string); +std::unique_ptr create_pymatching_graph_from_dem_with_correlations( + const rust::Str dem_string, bool enable_correlations); void add_edge( PyMatchingGraph& graph, diff --git a/crates/pecos-pymatching/pecos.toml b/crates/pecos-pymatching/pecos.toml index 16daa358c..635349187 100644 --- a/crates/pecos-pymatching/pecos.toml +++ b/crates/pecos-pymatching/pecos.toml @@ -4,13 +4,13 @@ version = 1 [dependencies.pymatching] -version = "2b72b2c558eec678656da20ab6c358aa123fb664" -url = "https://github.com/oscarhiggott/PyMatching/archive/2b72b2c558eec678656da20ab6c358aa123fb664.tar.gz" -sha256 = "1470520b66ad7899f85020664aeeadfc6e2967f0b5e19ad205829968b845cd70" +version = "c60642af5a5b342d633e2e2b818b9fdb9696e828" +url = "https://github.com/oscarhiggott/PyMatching/archive/c60642af5a5b342d633e2e2b818b9fdb9696e828.tar.gz" +sha256 = "c9e775619a791a3a0a58073447fd3f2c61a9804ecaa716fd753ef8f0a81783de" description = "MWPM decoder" [dependencies.stim] -version = "bd60b73525fd5a9b30839020eb7554ad369e4337" -url = "https://github.com/quantumlib/Stim/archive/bd60b73525fd5a9b30839020eb7554ad369e4337.tar.gz" -sha256 = "2a4be24295ce3018d79e08369b31e401a2d33cd8b3a75675d57dac3afd9de37d" +version = "213275795e49027772bea7c610b6aac3a80583e1" +url = "https://github.com/quantumlib/Stim/archive/213275795e49027772bea7c610b6aac3a80583e1.tar.gz" +sha256 = "b63c4ae94494a8819d440757776cf80a829ec1e30454fa7c9b0f670e981938ab" description = "Stabilizer simulator for QEC" diff --git a/crates/pecos-pymatching/src/bridge.cpp b/crates/pecos-pymatching/src/bridge.cpp index 30ddc3e42..7bbef999d 100644 --- a/crates/pecos-pymatching/src/bridge.cpp +++ b/crates/pecos-pymatching/src/bridge.cpp @@ -15,7 +15,6 @@ // PyMatching includes #include "pymatching/sparse_blossom/driver/user_graph.h" #include "pymatching/sparse_blossom/driver/mwpm_decoding.h" -#include "pymatching/sparse_blossom/driver/io.h" #include "pymatching/sparse_blossom/search/search_graph.h" #include "pymatching/rand/rand_gen.h" @@ -32,6 +31,7 @@ class PyMatchingGraph::Impl { std::unique_ptr mwpm_; pm::SearchFlooder* search_flooder_ = nullptr; double normalising_constant_ = 1.0; + bool enable_correlations_ = false; // Constructor Impl(size_t num_nodes, size_t num_observables) { @@ -39,7 +39,7 @@ class PyMatchingGraph::Impl { } // Initialize MWPM decoder when needed - void ensure_mwpm(bool include_search_graph = false) { + void ensure_mwpm(bool include_search_graph) { if (!mwpm_ || (include_search_graph && !search_flooder_)) { normalising_constant_ = user_graph_->get_edge_weight_normalising_constant(pm::NUM_DISTINCT_WEIGHTS); if (normalising_constant_ == 0) { @@ -73,12 +73,14 @@ PyMatchingGraph::PyMatchingGraph(size_t num_nodes, size_t num_observables) PyMatchingGraph::~PyMatchingGraph() = default; -std::unique_ptr PyMatchingGraph::from_dem(const std::string& dem_string) { +std::unique_ptr PyMatchingGraph::from_dem( + const std::string& dem_string, bool enable_correlations) { try { auto dem = stim::DetectorErrorModel(dem_string.c_str()); // Create user graph from DEM - auto user_graph = pm::detector_error_model_to_user_graph(dem); + auto user_graph = pm::detector_error_model_to_user_graph( + dem, enable_correlations, pm::NUM_DISTINCT_WEIGHTS); // Create PyMatchingGraph and move the user graph auto graph = std::make_unique( @@ -88,6 +90,7 @@ std::unique_ptr PyMatchingGraph::from_dem(const std::string& de // Replace the default user graph with the one from DEM graph->pimpl_->user_graph_ = std::make_unique(std::move(user_graph)); + graph->pimpl_->enable_correlations_ = enable_correlations; return graph; } catch (const std::exception& e) { @@ -305,7 +308,7 @@ bool PyMatchingGraph::is_boundary_node(size_t node) const { ExtendedMatchingResult PyMatchingGraph::decode_detection_events_64( const rust::Slice detection_events) { - pimpl_->ensure_mwpm(); + pimpl_->ensure_mwpm(pimpl_->enable_correlations_); // Convert detection events to vector of indices std::vector detections; @@ -316,7 +319,8 @@ ExtendedMatchingResult PyMatchingGraph::decode_detection_events_64( } try { - auto result = pm::decode_detection_events_for_up_to_64_observables(*pimpl_->mwpm_, detections); + auto result = pm::decode_detection_events_for_up_to_64_observables( + *pimpl_->mwpm_, detections, pimpl_->enable_correlations_); pimpl_->reset_mwpm(); ExtendedMatchingResult ext_result; @@ -338,7 +342,7 @@ ExtendedMatchingResult PyMatchingGraph::decode_detection_events_64( ExtendedMatchingResult PyMatchingGraph::decode_detection_events_extended( const rust::Slice detection_events) { - pimpl_->ensure_mwpm(); + pimpl_->ensure_mwpm(pimpl_->enable_correlations_); // Convert detection events std::vector detections; @@ -353,7 +357,8 @@ ExtendedMatchingResult PyMatchingGraph::decode_detection_events_extended( std::vector obs_vec(num_obs, 0); pm::total_weight_int weight = 0; - pm::decode_detection_events(*pimpl_->mwpm_, detections, obs_vec.data(), weight); + pm::decode_detection_events( + *pimpl_->mwpm_, detections, obs_vec.data(), weight, pimpl_->enable_correlations_); pimpl_->reset_mwpm(); ExtendedMatchingResult result; @@ -373,7 +378,7 @@ ExtendedMatchingResult PyMatchingGraph::decode_detection_events_extended( rust::Vec PyMatchingGraph::decode_to_matched_pairs( const rust::Slice detection_events) { - pimpl_->ensure_mwpm(); + pimpl_->ensure_mwpm(pimpl_->enable_correlations_); // Convert detection events std::vector detections; @@ -408,7 +413,7 @@ rust::Vec PyMatchingGraph::decode_to_matched_pairs( rust::Vec PyMatchingGraph::decode_to_edges( const rust::Slice detection_events) { - pimpl_->ensure_mwpm(); + pimpl_->ensure_mwpm(pimpl_->enable_correlations_); // Convert detection events std::vector detections; @@ -450,7 +455,7 @@ BatchDecodingResult PyMatchingGraph::decode_batch( bool bit_packed_shots, bool bit_packed_predictions) { - pimpl_->ensure_mwpm(); + pimpl_->ensure_mwpm(pimpl_->enable_correlations_); BatchDecodingResult result; result.predictions = rust::Vec(); @@ -493,7 +498,8 @@ BatchDecodingResult PyMatchingGraph::decode_batch( // Decode if (num_obs <= 64) { - auto res = pm::decode_detection_events_for_up_to_64_observables(*pimpl_->mwpm_, detections); + auto res = pm::decode_detection_events_for_up_to_64_observables( + *pimpl_->mwpm_, detections, pimpl_->enable_correlations_); if (bit_packed_predictions) { // Pack obs_mask into bytes @@ -518,7 +524,8 @@ BatchDecodingResult PyMatchingGraph::decode_batch( std::vector obs_vec(num_obs, 0); pm::total_weight_int weight = 0; - pm::decode_detection_events(*pimpl_->mwpm_, detections, obs_vec.data(), weight); + pm::decode_detection_events( + *pimpl_->mwpm_, detections, obs_vec.data(), weight, pimpl_->enable_correlations_); if (bit_packed_predictions) { // Pack observables into bytes @@ -678,7 +685,12 @@ std::unique_ptr create_pymatching_graph_with_observables( } std::unique_ptr create_pymatching_graph_from_dem(const rust::Str dem_string) { - return PyMatchingGraph::from_dem(std::string(dem_string)); + return PyMatchingGraph::from_dem(std::string(dem_string), false); +} + +std::unique_ptr create_pymatching_graph_from_dem_with_correlations( + const rust::Str dem_string, bool enable_correlations) { + return PyMatchingGraph::from_dem(std::string(dem_string), enable_correlations); } void add_edge( diff --git a/crates/pecos-pymatching/src/bridge.rs b/crates/pecos-pymatching/src/bridge.rs index f267743da..b2becdc86 100644 --- a/crates/pecos-pymatching/src/bridge.rs +++ b/crates/pecos-pymatching/src/bridge.rs @@ -73,6 +73,19 @@ pub(crate) mod ffi { fn create_pymatching_graph_from_dem(dem_string: &str) -> Result>; + /// Create a `PyMatching` graph from a DEM string with correlation support. + /// + /// When `enable_correlations` is true, the decoder tracks edge correlations + /// during graph construction and uses them during decoding. + /// + /// # Errors + /// + /// Returns a CXX exception if the DEM string is malformed. + fn create_pymatching_graph_from_dem_with_correlations( + dem_string: &str, + enable_correlations: bool, + ) -> Result>; + // ===== Edge Management ===== /// Add an edge between two nodes. diff --git a/crates/pecos-pymatching/src/core_traits.rs b/crates/pecos-pymatching/src/core_traits.rs index 708aa4042..8a96c2b16 100644 --- a/crates/pecos-pymatching/src/core_traits.rs +++ b/crates/pecos-pymatching/src/core_traits.rs @@ -7,8 +7,8 @@ use crate::decoder::{CheckMatrix, CheckMatrixConfig, DecodingResult, PyMatchingD use crate::errors::PyMatchingError; use ndarray::{ArrayView1, ArrayView2}; use pecos_decoder_core::{ - BatchDecoder, CheckMatrixDecoder, Decoder, DecodingStats, DemDecoder, DetailedDecoder, - MatchedEdge, MatchedPair as CoreMatchedPair, + BatchDecoder, CheckMatrixDecoder, Decoder, DecoderError, DecodingStats, DemDecoder, + DetailedDecoder, MatchedEdge, MatchedPair as CoreMatchedPair, ObservableDecoder, }; /// Implement the core Decoder trait for `PyMatchingDecoder` @@ -184,6 +184,52 @@ impl DetailedDecoder for PyMatchingDecoder { } } +/// Implement `ObservableDecoder` 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 { + let result = self + .decode(syndrome) + .map_err(|e| DecoderError::DecodingFailed(e.to_string()))?; + let mut mask = 0u64; + for (i, &v) in result.observable.iter().enumerate() { + if v != 0 { + mask |= 1 << i; + } + } + Ok(mask) + } + + fn decode_batch_to_observables( + &mut self, + shots: &[u8], + num_shots: usize, + num_detectors: usize, + ) -> Result, DecoderError> { + use crate::decoder::BatchConfig; + let config = BatchConfig { + bit_packed_input: false, + bit_packed_output: true, + return_weights: false, + }; + let result = self + .decode_batch_with_config(shots, num_shots, num_detectors, config) + .map_err(|e| DecoderError::DecodingFailed(e.to_string()))?; + + // Convert per-shot bit-packed predictions to u64 masks. + let mut masks = Vec::with_capacity(num_shots); + for pred in &result.predictions { + let mut mask = 0u64; + for (byte_idx, &byte) in pred.iter().enumerate() { + mask |= u64::from(byte) << (byte_idx * 8); + } + masks.push(mask); + } + Ok(masks) + } +} + #[cfg(test)] mod tests { use super::*; diff --git a/crates/pecos-pymatching/src/decoder.rs b/crates/pecos-pymatching/src/decoder.rs index 55feb0e3b..f63a67d6d 100644 --- a/crates/pecos-pymatching/src/decoder.rs +++ b/crates/pecos-pymatching/src/decoder.rs @@ -535,6 +535,31 @@ impl PyMatchingDecoder { Ok(Self { graph, config }) } + /// Create a decoder from a DEM string with correlation support + /// + /// When `enable_correlations` is true, the decoder tracks edge correlations + /// during graph construction and uses them during decoding. + /// + /// # Errors + /// Returns an error if the DEM string is invalid or cannot be parsed. + pub fn from_dem_with_correlations(dem_string: &str, enable_correlations: bool) -> Result { + let graph = ffi::create_pymatching_graph_from_dem_with_correlations( + dem_string, + enable_correlations, + )?; + + let num_nodes = ffi::pymatching_get_num_nodes(&graph); + let num_observables = ffi::pymatching_get_num_observables(&graph); + + let config = PyMatchingConfig { + num_neighbours: None, + num_nodes: Some(num_nodes), + num_observables, + }; + + Ok(Self { graph, config }) + } + /// Create a decoder from a check matrix /// /// The check matrix should be in sparse format where: diff --git a/crates/pecos-qasm/src/engine.rs b/crates/pecos-qasm/src/engine.rs index a4413d397..f795d1b41 100644 --- a/crates/pecos-qasm/src/engine.rs +++ b/crates/pecos-qasm/src/engine.rs @@ -623,7 +623,8 @@ impl QASMEngine { | GateType::MeasCrosstalkLocalPayload | GateType::MeasCrosstalkGlobalPayload | GateType::QFree - | GateType::Custom => Ok(()), // No-op gates (QFree is just a marker, Custom is a placeholder) + | GateType::Custom + | GateType::TrackedPauliMeta => Ok(()), // No-op gates GateType::X | GateType::Z | GateType::Y @@ -650,7 +651,7 @@ impl QASMEngine { | GateType::SYY | GateType::SYYdg => self.process_two_qubit_gate(gate.gate_type, &qubits), // Gates not yet supported in QASM engine - GateType::SWAP | GateType::CCX | GateType::CRZ | GateType::CH => { + GateType::SWAP | GateType::CCX | GateType::CRZ | GateType::CH | GateType::Channel => { Err(PecosError::Processing(format!( "Gate type {:?} is not yet supported in the QASM engine", gate.gate_type diff --git a/crates/pecos-qasm/src/qasm_to_phir.rs b/crates/pecos-qasm/src/qasm_to_phir.rs index ed55a47a4..576e0404a 100644 --- a/crates/pecos-qasm/src/qasm_to_phir.rs +++ b/crates/pecos-qasm/src/qasm_to_phir.rs @@ -228,14 +228,14 @@ impl Converter { Vec::with_capacity(measurements.len()); for (qubit_ssa, reg_name, bit_idx) in measurements { - let meas_result = self.new_ssa(); + let meas_id = self.new_ssa(); block.add_instruction(Instruction::new( Operation::Quantum(QuantumOp::Measure), vec![*qubit_ssa], - vec![meas_result], + vec![meas_id], vec![Type::Bit], )); - measure_results.push((meas_result, reg_name.clone(), *bit_idx)); + measure_results.push((meas_id, reg_name.clone(), *bit_idx)); } // Step 2: Group by register and combine bits diff --git a/crates/pecos-qec/Cargo.toml b/crates/pecos-qec/Cargo.toml index f2acb8fee..30a043360 100644 --- a/crates/pecos-qec/Cargo.toml +++ b/crates/pecos-qec/Cargo.toml @@ -15,15 +15,21 @@ readme = "README.md" ndarray.workspace = true pecos-core.workspace = true pecos-decoder-core.workspace = true +pecos-num.workspace = true pecos-quantum.workspace = true pecos-simulators.workspace = true pecos-random.workspace = true rand.workspace = true rand_core.workspace = true rayon.workspace = true +serde_json.workspace = true smallvec.workspace = true thiserror.workspace = true wide.workspace = true +[[example]] +name = "surface_d3_fault_catalog_lookup" +path = "../../examples/surface/d3_fault_catalog_lookup.rs" + [lints] workspace = true diff --git a/crates/pecos-qec/examples/influence_builder_example.rs b/crates/pecos-qec/examples/influence_builder_example.rs index 363b06fe5..0266c42d5 100644 --- a/crates/pecos-qec/examples/influence_builder_example.rs +++ b/crates/pecos-qec/examples/influence_builder_example.rs @@ -3,14 +3,12 @@ //! Pipeline steps: //! 1. Build a syndrome extraction circuit using `DagCircuit` //! 2. Use `InfluenceBuilder` to extract detectors and build influence map -//! 3. Use `NoisySampler` for CPU-based noisy sampling +//! 3. Use `DemSampler` for fast CPU-based noisy sampling //! //! Run with: cargo run --example `influence_builder_example` --release -p pecos-qec use pecos_qec::fault_tolerance::InfluenceBuilder; -use pecos_qec::fault_tolerance::noisy_sampler::{ - NoisySampler, SamplingStatistics, UniformNoiseModel, -}; +use pecos_qec::fault_tolerance::dem_builder::DemSampler; use pecos_quantum::DagCircuit; /// Build a simple repetition code syndrome extraction circuit. @@ -42,7 +40,7 @@ fn build_repetition_code_circuit(num_rounds: usize) -> DagCircuit { } fn main() { - println!("CPU Pipeline Example: Circuit -> Influence Map -> CPU Sampling\n"); + println!("CPU Pipeline Example: Circuit -> Influence Map -> DemSampler\n"); println!("{:=<70}", ""); // ========================================================================= @@ -57,7 +55,7 @@ fn main() { // ========================================================================= // Build influence map with InfluenceBuilder // ========================================================================= - let builder = InfluenceBuilder::new(&circuit).with_logical_z(vec![0, 1, 2]); // Z logical on all data qubits + let builder = InfluenceBuilder::new(&circuit).with_z(&[0, 1, 2]); // Z logical on all data qubits let influence_map = builder.build(); @@ -78,28 +76,24 @@ fn main() { } // ========================================================================= - // Sample with CPU NoisySampler + // Sample with DemSampler // ========================================================================= let p_error = 0.001; // 0.1% error rate per location - let noise_model = UniformNoiseModel::depolarizing(p_error); let seed = 42u64; + let num_locations = influence_map.locations.len(); + let per_location_probs = vec![p_error; num_locations]; - let mut sampler = NoisySampler::new(&influence_map, noise_model, seed); + let sampler = DemSampler::from_influence_map(&influence_map, &per_location_probs); - println!("\n3. Sampling with CPU NoisySampler:"); + println!("\n3. Sampling with DemSampler:"); println!(" Error rate: {p_error}"); + println!(" Mechanisms: {}", sampler.num_mechanisms()); let num_shots = 100_000; let start = std::time::Instant::now(); - let results = sampler.sample(num_shots); + let stats = sampler.sample_statistics(num_shots, seed); let elapsed = start.elapsed(); - // Collect statistics - let mut stats = SamplingStatistics::new(); - for result in &results { - stats.record(result); - } - println!(" Shots: {num_shots}"); println!(" Time: {:.2}ms", elapsed.as_secs_f64() * 1000.0); #[allow(clippy::cast_precision_loss)] @@ -116,7 +110,6 @@ fn main() { " Undetectable error rate: {:.6}%", stats.undetectable_rate() * 100.0 ); - println!(" Avg faults per shot: {:.2}", stats.average_faults()); // ========================================================================= // Compare with different error rates @@ -129,14 +122,9 @@ fn main() { println!(" {:->8} {:->15} {:->15}", "", "", ""); for p in [0.0001, 0.0005, 0.001, 0.002, 0.005] { - let noise = UniformNoiseModel::depolarizing(p); - let mut sampler = NoisySampler::new(&influence_map, noise, seed); - let results = sampler.sample(50_000); - - let mut stats = SamplingStatistics::new(); - for result in &results { - stats.record(result); - } + let probs = vec![p; num_locations]; + let sampler = DemSampler::from_influence_map(&influence_map, &probs); + let stats = sampler.sample_statistics(50_000, seed); println!( " {:>8.4} {:>14.4}% {:>14.4}%", diff --git a/crates/pecos-qec/src/dem_stab.rs b/crates/pecos-qec/src/dem_stab.rs new file mode 100644 index 000000000..2b3532961 --- /dev/null +++ b/crates/pecos-qec/src/dem_stab.rs @@ -0,0 +1,267 @@ +// 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. + +//! `DemStabSim` -- Clifford + depolarizing-family noise simulator backed by DEM sampling. +//! +//! Wraps the existing DAG -> fault-influence -> DEM-sampler pipeline as a single +//! simulator type that consumes a static [`DagCircuit`] plus detector / observable +//! definitions plus a [`NoiseConfig`] and produces shot batches of detector and +//! observable flips. +//! +//! # Scope +//! +//! Clifford circuits only, no classical feed-forward. For adaptive circuits use +//! `sparse_stab` + `pecos-neo` instead. For non-Clifford circuits use `CliffordRz`, +//! `STN`, or `MAST`. +//! +//! # Example +//! +//! ``` +//! use pecos_qec::dem_stab::DemStabSim; +//! use pecos_qec::fault_tolerance::dem_builder::{DemOutput, DetectorDef, NoiseConfig}; +//! use pecos_quantum::DagCircuit; +//! use rand::SeedableRng; +//! use rand::rngs::SmallRng; +//! +//! let mut dag = DagCircuit::new(); +//! dag.pz(&[2]); +//! dag.cx(&[(0, 2)]); +//! dag.cx(&[(1, 2)]); +//! dag.mz(&[2]); +//! +//! let sim = DemStabSim::builder() +//! .circuit(dag) +//! .noise(NoiseConfig::uniform(0.01)) +//! .detectors(vec![DetectorDef::new(0).with_records([-1])]) +//! .build() +//! .unwrap(); +//! +//! let mut rng = SmallRng::seed_from_u64(42); +//! let batch = sim.sample_batch(100, &mut rng); +//! assert_eq!(batch.detector_flips.len(), 100); +//! ``` + +use crate::fault_tolerance::dem_builder::{ + DemOutput, DemSampler, DemSamplerBuilder, DetectorDef, DetectorErrorModel, + DetectorValidationError, NoiseConfig, PerGateTypeNoise, +}; +use crate::fault_tolerance::propagator::DagFaultAnalyzer; +use pecos_quantum::DagCircuit; +use rand_core::Rng; +use thiserror::Error; + +/// Errors that can occur when building a [`DemStabSim`]. +#[derive(Debug, Error)] +pub enum DemStabError { + /// Builder called without a circuit. + #[error("DemStabSim requires a circuit; call .circuit(dag) before .build()")] + MissingCircuit, + /// Detector definitions are invalid for the circuit. + #[error(transparent)] + DetectorValidation(#[from] DetectorValidationError), +} + +/// Shot-batch output from [`DemStabSim::sample_batch`]. +/// +/// `detector_flips[i]` is the bit-vector of detector outcomes for shot `i` (length +/// equals the number of registered detectors). `observable_flips[i]` is the +/// corresponding observable outcomes. +#[derive(Debug, Clone)] +pub struct DemStabShotBatch { + /// Per-shot detector flip vectors. Outer length = `num_shots`, inner length = `num_detectors`. + pub detector_flips: Vec>, + /// Per-shot observable flip vectors. Outer length = `num_shots`, inner length = `num_observables`. + pub observable_flips: Vec>, +} + +/// Clifford + depolarizing-family noise simulator backed by DEM sampling. +/// +/// Built once via [`DemStabSim::builder`], sampled many times via [`Self::sample_batch`]. +/// The underlying [`DemSampler`] is constructed eagerly at build time; subsequent +/// shots reuse the cached mechanism table. +#[derive(Debug, Clone)] +pub struct DemStabSim { + sampler: DemSampler, + /// Detector definitions preserved from the builder, used to produce + /// a text-serializable [`DetectorErrorModel`] with full metadata. + detectors: Vec, + observables: Vec, +} + +impl DemStabSim { + /// Start building a [`DemStabSim`]. + #[must_use] + pub fn builder() -> DemStabSimBuilder { + DemStabSimBuilder::default() + } + + /// Number of registered detectors. + #[must_use] + pub fn num_detectors(&self) -> usize { + self.sampler.num_detectors() + } + + /// Number of registered observables. + #[must_use] + pub fn num_observables(&self) -> usize { + self.sampler.num_observables() + } + + /// Number of error mechanisms in the compiled DEM. + #[must_use] + pub fn num_mechanisms(&self) -> usize { + self.sampler.num_mechanisms() + } + + /// Access the underlying [`DemSampler`] for advanced use (e.g. statistics-only APIs). + #[must_use] + pub fn sampler(&self) -> &DemSampler { + &self.sampler + } + + /// Produce a [`DetectorErrorModel`] reflecting the compiled mechanism + /// set and the detector / observable definitions the builder was + /// given. Use [`DetectorErrorModel::to_string`] for Stim-compatible + /// text output. + /// + /// Note: the probabilities are recovered from the sampler's stored + /// `u64` thresholds, which round-trips to ~machine precision. + #[must_use] + pub fn detector_error_model(&self) -> DetectorErrorModel { + let mut dem = self.sampler.to_detector_error_model(); + for det in &self.detectors { + dem.add_detector(det.clone()); + } + for obs in &self.observables { + dem.add_observable(obs.clone()); + } + dem + } + + /// Sample `num_shots` independent shots from the compiled DEM. + #[must_use] + pub fn sample_batch(&self, num_shots: usize, rng: &mut R) -> DemStabShotBatch { + let (detector_flips, observable_flips) = self.sampler.sample_batch(num_shots, rng); + DemStabShotBatch { + detector_flips, + observable_flips, + } + } +} + +/// Builder for [`DemStabSim`]. +#[derive(Debug, Default)] +pub struct DemStabSimBuilder { + circuit: Option, + noise: NoiseConfig, + per_gate_noise: Option, + detectors: Vec, + observables: Vec, + measurement_order: Option>, +} + +impl DemStabSimBuilder { + /// Set the circuit. Required. + #[must_use] + pub fn circuit(mut self, dag: DagCircuit) -> Self { + self.circuit = Some(dag); + self + } + + /// Set the uniform-depolarizing noise configuration. When both this + /// and [`Self::per_gate_noise`] are set, the per-gate spec takes + /// precedence. + #[must_use] + pub fn noise(mut self, config: NoiseConfig) -> Self { + self.noise = config; + self + } + + /// Set a per-gate-type per-Pauli noise specification. Overrides + /// [`Self::noise`] scalars for any gate type present in the spec. + /// Intended consumer for `pecos-lindblad::PauliLindbladModel` + /// adapter output. + #[must_use] + pub fn per_gate_noise(mut self, cfg: PerGateTypeNoise) -> Self { + self.per_gate_noise = Some(cfg); + self + } + + /// Register detectors by [`DetectorDef`]. + #[must_use] + pub fn detectors(mut self, detectors: Vec) -> Self { + self.detectors = detectors; + self + } + + /// Register measurement-record observables. + #[must_use] + pub fn observables(mut self, observables: Vec) -> Self { + self.observables = observables; + self + } + + /// Set the measurement order mapping from a `TickCircuit` (advanced). + #[must_use] + pub fn measurement_order(mut self, order: Vec) -> Self { + self.measurement_order = Some(order); + self + } + + /// Build the [`DemStabSim`], consuming the builder. + /// + /// # Errors + /// + /// Returns [`DemStabError::MissingCircuit`] if no circuit was set. + pub fn build(self) -> Result { + let dag = self.circuit.ok_or(DemStabError::MissingCircuit)?; + + let analyzer = DagFaultAnalyzer::new(&dag); + let influence_map = analyzer.build_influence_map(); + + let detector_records: Vec> = + self.detectors.iter().map(|d| d.records.to_vec()).collect(); + let observable_records: Vec> = self + .observables + .iter() + .map(|o| o.records.to_vec()) + .collect(); + + let mut builder = DemSamplerBuilder::new(&influence_map); + builder = if let Some(cfg) = self.per_gate_noise { + builder.with_per_gate_noise(cfg) + } else { + builder.with_noise( + self.noise.p1, + self.noise.p2, + self.noise.p_meas, + self.noise.p_prep, + ) + }; + builder = builder + .with_detector_records(detector_records) + .with_observable_records(observable_records); + + if let Some(order) = self.measurement_order { + builder = builder.with_measurement_order(order); + } + + let detectors = self.detectors.clone(); + let observables = self.observables.clone(); + + Ok(DemStabSim { + sampler: builder.build()?, + detectors, + observables, + }) + } +} diff --git a/crates/pecos-qec/src/fault_tolerance.rs b/crates/pecos-qec/src/fault_tolerance.rs index b73107f66..8aa05b7a7 100644 --- a/crates/pecos-qec/src/fault_tolerance.rs +++ b/crates/pecos-qec/src/fault_tolerance.rs @@ -18,14 +18,17 @@ //! For a full guide, see `docs/user-guide/fault-tolerance.md`. pub mod circuit_runner; +pub mod correlation; pub mod decoder_integration; pub mod dem_builder; +pub mod fault_sampler; pub mod gadget_checker; pub mod influence_builder; -pub mod noisy_sampler; +pub mod lookup_decoder; pub mod pauli_prop_checker; pub mod propagator; pub mod stabilizer_flip_checker; +pub mod targeted_lookup_decoder; use pecos_core::QubitId; use pecos_core::gate_type::GateType; @@ -53,11 +56,12 @@ pub use pauli_prop_checker::{ has_syndrome, propagate_fault, propagate_faults, }; pub use propagator::{ - DagFaultAnalyzer, DagFaultInfluenceMap, DagPropagator, DagSpacetimeLocation, DetectorId, - Direction, FaultInfluence, FaultInfluenceMap, InfluenceBasedChecker, LogicalId, MeasurementId, - TickFaultAnalyzer, apply_gate, propagate_backward_from_node, propagate_backward_from_tick, - propagate_fault_backward, propagate_observable_backward, propagate_sparse_dag, - propagate_through_circuit, propagate_through_dag, propagate_tick_range, + DagFaultAnalyzer, DagFaultInfluenceMap, DagPropagator, DagSpacetimeLocation, DemOutputKind, + DemOutputMetadata, DetectorId, Direction, FaultInfluence, FaultInfluenceMap, + InfluenceBasedChecker, MeasurementId, TickFaultAnalyzer, TrackedPauliId, apply_gate, + propagate_backward_from_node, propagate_backward_from_tick, propagate_fault_backward, + propagate_observable_backward, propagate_sparse_dag, propagate_through_circuit, + propagate_through_dag, propagate_tick_range, }; pub use stabilizer_flip_checker::{ ErrorClass, StabilizerFlipAnalysis, StabilizerFlipChecker, StabilizerFlips, diff --git a/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs b/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs index d4b5c2cd6..1f16ca073 100644 --- a/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs +++ b/crates/pecos-qec/src/fault_tolerance/circuit_runner.rs @@ -60,7 +60,7 @@ pub fn extract_spacetime_locations( // Iterate through all ticks for (tick_idx, tick) in circuit.iter_ticks() { - for (gate_idx, gate) in tick.gates().iter().enumerate() { + for gate in tick.iter_gate_batches() { let qubits: Vec = gate.qubits.iter().copied().collect(); let is_measurement = matches!(gate.gate_type, GateType::MZ | GateType::MeasureFree); @@ -69,7 +69,7 @@ pub fn extract_spacetime_locations( qubits, is_measurement, // Measurements get "before" errors gate.gate_type, - gate_idx, + gate.batch_index(), )); } } @@ -105,10 +105,10 @@ fn apply_fault(sim: &mut S, fault: &PauliFault) { /// simulator-level batch optimizations (gate fusion, SIMD batching, etc.). fn apply_tick_gates(sim: &mut S, tick: &pecos_quantum::Tick) { // For ticks with few gates, skip consolidation overhead - let gate_count = tick.gates().len(); + let gate_count = tick.len(); if gate_count <= 2 { - for gate in tick.gates() { - apply_gate(sim, gate); + for gate in tick.iter_gate_batches() { + apply_gate(sim, gate.as_gate()); } return; } @@ -139,7 +139,7 @@ fn apply_tick_gates(sim: &mut S, tick: &pecos_quantum::Tick let mut mz_qubits: Vec = Vec::new(); let mut pz_qubits: Vec = Vec::new(); - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { match gate.gate_type { GateType::H => h_qubits.extend(gate.qubits.iter()), GateType::X => x_qubits.extend(gate.qubits.iter()), @@ -208,7 +208,7 @@ fn apply_tick_gates(sim: &mut S, tick: &pecos_quantum::Tick GateType::I => {} _ => { // Fallback: apply individually for unsupported gate types - apply_gate(sim, gate); + apply_gate(sim, gate.as_gate()); } } } @@ -1315,7 +1315,8 @@ mod tests { let mut circuit = TickCircuit::new(); circuit.tick().pz(&[0, 1, 2]); // All prepared circuit.tick().h(&[0]); - circuit.tick().cx(&[(0, 1), (0, 2)]); + circuit.tick().cx(&[(0, 1)]); + circuit.tick().cx(&[(0, 2)]); // No measurement - outputs go to next stage let checker = FaultChecker::new(&circuit); @@ -1527,9 +1528,13 @@ mod tests { circuit.tick().h(&[0, 1, 3]); // Entangle to create logical |0> - circuit.tick().cx(&[(0, 2), (1, 2)]); - circuit.tick().cx(&[(0, 4), (1, 5), (3, 5)]); - circuit.tick().cx(&[(0, 6), (1, 6), (3, 6)]); + circuit.tick().cx(&[(0, 2)]); + circuit.tick().cx(&[(1, 2)]); + circuit.tick().cx(&[(0, 4), (1, 5)]); + circuit.tick().cx(&[(3, 5)]); + circuit.tick().cx(&[(0, 6)]); + circuit.tick().cx(&[(1, 6)]); + circuit.tick().cx(&[(3, 6)]); circuit.tick().cx(&[(3, 4)]); circuit diff --git a/crates/pecos-qec/src/fault_tolerance/correlation.rs b/crates/pecos-qec/src/fault_tolerance/correlation.rs new file mode 100644 index 000000000..7c4430b3e --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/correlation.rs @@ -0,0 +1,617 @@ +// 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. + +//! Detector correlation analysis for DEM validation. +//! +//! Computes k-body detector firing rates from sampled syndromes and +//! compares them between simulation and DEM outputs. This captures +//! both marginal detection rates and the correlated error structure +//! that determines decoding quality. +//! +//! # Flip frequency matrix +//! +//! The pairwise flip frequency matrix `M` for `n` detectors: +//! - `M[i][i]` = P(detector i fires) (marginal rate) +//! - `M[i][j]` = 0.5 * P(i AND j fire) (half joint rate, i != j) +//! +//! # Higher-order correlations +//! +//! K-body rates map each k-subset of detectors to its joint firing +//! probability. For order 1 these are marginals; for order 2, pairwise; +//! for order 3, triple correlations that test whether the DEM's +//! independent error decomposition is adequate. + +use std::collections::{BTreeMap, BTreeSet}; + +type CorrelationEntry<'a> = (&'a Vec, f64, f64); + +fn count_as_f64(items: &[T]) -> f64 { + items.iter().fold(0.0, |count, _| count + 1.0) +} + +/// Flat `n x n` detector flip frequency matrix. +/// +/// Stored row-major. Use `index(i, j, n) = i * n + j`. +#[must_use] +pub fn flip_matrix_from_fired(fired_per_shot: &[Vec], num_detectors: usize) -> Vec { + let n = num_detectors; + let shots = fired_per_shot.len(); + if shots == 0 { + return vec![0.0; n * n]; + } + + let inv = 1.0 / count_as_f64(fired_per_shot); + let half_inv = 0.5 * inv; + let mut m = vec![0.0; n * n]; + + for fired in fired_per_shot { + for (ai, &a) in fired.iter().enumerate() { + let a = a as usize; + if a >= n { + continue; + } + m[a * n + a] += inv; + for &b in &fired[ai + 1..] { + let b = b as usize; + if b >= n { + continue; + } + m[a * n + b] += half_inv; + m[b * n + a] += half_inv; + } + } + } + + m +} + +/// Per-round flip frequency matrices. +/// +/// Returns one flat `k x k` matrix per round, where `k = dets_per_round`. +#[must_use] +pub fn flip_matrices_by_round( + fired_per_shot: &[Vec], + num_detectors: usize, + dets_per_round: usize, +) -> Vec> { + let k = dets_per_round; + let num_rounds = num_detectors.div_ceil(k); + let shots = fired_per_shot.len(); + if shots == 0 { + return vec![vec![0.0; k * k]; num_rounds]; + } + + let inv = 1.0 / count_as_f64(fired_per_shot); + let half_inv = 0.5 * inv; + let mut matrices = vec![vec![0.0; k * k]; num_rounds]; + + for fired in fired_per_shot { + // Bin by round + let mut round_local: Vec> = vec![Vec::new(); num_rounds]; + for &d in fired { + let r = d as usize / k; + let local = d as usize % k; + if r >= num_rounds { + continue; + } + if let Ok(local) = u32::try_from(local) { + round_local[r].push(local); + } + } + + for (r, local_ids) in round_local.iter().enumerate() { + let mat = &mut matrices[r]; + for (ai, &a) in local_ids.iter().enumerate() { + let a = a as usize; + mat[a * k + a] += inv; + for &b in &local_ids[ai + 1..] { + let b = b as usize; + mat[a * k + b] += half_inv; + mat[b * k + a] += half_inv; + } + } + } + } + + matrices +} + +/// K-body detector firing rates up to `max_order`. +/// +/// Returns a map from sorted detector index tuples to joint firing +/// probability. Keys are ordered ascending. +#[must_use] +pub fn k_body_rates( + fired_per_shot: &[Vec], + num_detectors: usize, + max_order: usize, +) -> BTreeMap, f64> { + let shots = fired_per_shot.len(); + if shots == 0 { + return BTreeMap::new(); + } + + let inv = 1.0 / count_as_f64(fired_per_shot); + let mut rates: BTreeMap, f64> = BTreeMap::new(); + + for fired in fired_per_shot { + let valid_fired: Vec = fired + .iter() + .copied() + .filter(|&d| (d as usize) < num_detectors) + .collect(); + let n = valid_fired.len().min(max_order); + for order in 1..=n { + for_each_combination(&valid_fired, order, |combo| { + *rates.entry(combo.to_vec()).or_insert(0.0) += inv; + }); + } + } + + rates +} + +/// Per-round k-body rates. Detector indices in the returned maps are +/// round-local (0..dets_per_round-1). +#[must_use] +pub fn k_body_rates_by_round( + fired_per_shot: &[Vec], + num_detectors: usize, + dets_per_round: usize, + max_order: usize, +) -> Vec, f64>> { + let k = dets_per_round; + let num_rounds = num_detectors.div_ceil(k); + let shots = fired_per_shot.len(); + if shots == 0 { + return vec![BTreeMap::new(); num_rounds]; + } + + let inv = 1.0 / count_as_f64(fired_per_shot); + let mut round_rates: Vec, f64>> = vec![BTreeMap::new(); num_rounds]; + + for fired in fired_per_shot { + let mut round_local: Vec> = vec![Vec::new(); num_rounds]; + for &d in fired { + let r = d as usize / k; + let local = d as usize % k; + if r >= num_rounds { + continue; + } + if let Ok(local) = u32::try_from(local) { + round_local[r].push(local); + } + } + + for (r, local_ids) in round_local.iter().enumerate() { + let n = local_ids.len().min(max_order); + let rr = &mut round_rates[r]; + for order in 1..=n { + for_each_combination(local_ids, order, |combo| { + *rr.entry(combo.to_vec()).or_insert(0.0) += inv; + }); + } + } + } + + round_rates +} + +/// Compare k-body rates between two sets, grouped by order. +/// +/// Returns a map from order to `(max_rel_error, rms_rel_error, worst_event)`. +#[must_use] +pub fn compare_k_body( + sim: &BTreeMap, f64>, + dem: &BTreeMap, f64>, + min_rate: f64, +) -> BTreeMap)> { + let all_keys: BTreeSet<&Vec> = sim.keys().chain(dem.keys()).collect(); + + let mut by_order: BTreeMap>> = BTreeMap::new(); + for &key in &all_keys { + let s = sim.get(key).copied().unwrap_or(0.0); + let d = dem.get(key).copied().unwrap_or(0.0); + by_order.entry(key.len()).or_default().push((key, s, d)); + } + + let mut result = BTreeMap::new(); + for (order, entries) in &by_order { + let mut max_err = 0.0_f64; + let mut worst: Vec = Vec::new(); + let mut sum_sq = 0.0; + let mut count = 0.0; + + for &(key, s, d) in entries { + if s > min_rate { + let rel = (d / s - 1.0).abs(); + if rel > max_err { + max_err = rel; + worst.clone_from(key); + } + sum_sq += rel * rel; + count += 1.0; + } + } + + let rms = if count > 0.0 { + (sum_sq / count).sqrt() + } else { + 0.0 + }; + result.insert(*order, (max_err, rms, worst)); + } + + result +} + +/// Compare two flat flip matrices. Returns `(max_rel_err, frob_rel_err, worst_i, worst_j)`. +#[must_use] +pub fn compare_flip_matrices( + sim: &[f64], + dem: &[f64], + n: usize, + min_rate: f64, +) -> (f64, f64, usize, usize) { + let mut max_err = 0.0_f64; + let mut worst_i = 0; + let mut worst_j = 0; + let mut sum_sq_diff = 0.0; + let mut sum_sq_sim = 0.0; + + for i in 0..n { + for j in 0..n { + let idx = i * n + j; + let s = sim[idx]; + let d = dem[idx]; + let diff = d - s; + sum_sq_diff += diff * diff; + sum_sq_sim += s * s; + if s > min_rate { + let rel = diff.abs() / s; + if rel > max_err { + max_err = rel; + worst_i = i; + worst_j = j; + } + } + } + } + + let frob = sum_sq_diff.sqrt() / sum_sq_sim.sqrt().max(1e-30); + (max_err, frob, worst_i, worst_j) +} + +// --------------------------------------------------------------------------- +// Hybrid DEM: fit mechanism probabilities to target marginals +// --------------------------------------------------------------------------- + +/// A DEM mechanism: probability + detector/DEM-output sets. +#[derive(Debug, Clone)] +pub struct DemMechanism { + pub probability: f64, + pub detectors: Vec, + pub observables: Vec, +} + +/// Fit DEM mechanism probabilities to match target detector marginals. +/// +/// Given a set of mechanisms (each with a detector set and initial +/// probability) and target per-detector marginal rates, adjusts the +/// mechanism probabilities so the DEM's independent-error marginals +/// match the targets as closely as possible. +/// +/// Uses iterative proportional fitting on the exact DEM marginal equation: +/// +/// ```text +/// p_d = 1/2 - 1/2 * prod_{m: d in S_m} (1 - 2*q_m) +/// ``` +/// +/// Each iteration computes current marginals, then scales each mechanism +/// by the geometric mean of (target/current) ratios for the detectors +/// it affects. Mechanisms with no detector overlap are untouched. +/// +/// Returns the fitted mechanisms and per-detector residual errors. +#[must_use] +pub fn fit_dem_to_marginals( + mechanisms: &[DemMechanism], + target_marginals: &[f64], + max_iterations: usize, + tolerance: f64, +) -> (Vec, Vec) { + let num_dets = target_marginals.len(); + let n_mech = mechanisms.len(); + + // Build sparse incidence: for each detector, which mechanisms touch it + let mut det_to_mechs: Vec> = vec![Vec::new(); num_dets]; + for (m, mech) in mechanisms.iter().enumerate() { + for &d in &mech.detectors { + if (d as usize) < num_dets { + det_to_mechs[d as usize].push(m); + } + } + } + + let mut q: Vec = mechanisms.iter().map(|m| m.probability).collect(); + + for _iter in 0..max_iterations { + // Compute current marginals from mechanism probabilities + let mut current = vec![0.0_f64; num_dets]; + for d in 0..num_dets { + let mut prod = 1.0; + for &m in &det_to_mechs[d] { + prod *= 1.0 - 2.0 * q[m]; + } + current[d] = (1.0 - prod) / 2.0; + } + + // Compute per-detector ratios + let mut ratios = vec![1.0_f64; num_dets]; + for d in 0..num_dets { + if current[d] > 1e-20 { + ratios[d] = target_marginals[d] / current[d]; + } else if target_marginals[d] > 1e-20 { + ratios[d] = 10.0; // large but bounded nudge + } + } + + // Scale each mechanism by geometric mean of its detector ratios + let mut max_change = 0.0_f64; + for m in 0..n_mech { + let dets = &mechanisms[m].detectors; + if dets.is_empty() { + continue; + } + let mut log_ratio = 0.0; + let mut count = 0; + for &d in dets { + if (d as usize) < num_dets { + log_ratio += ratios[d as usize].max(1e-10).ln(); + count += 1; + } + } + if count == 0 { + continue; + } + let scale = (log_ratio / f64::from(count)).exp(); + let new_q = (q[m] * scale).clamp(0.0, 0.499); + max_change = max_change.max((new_q - q[m]).abs()); + q[m] = new_q; + } + + if max_change < tolerance { + break; + } + } + + // Compute final residuals + let mut residuals = vec![0.0; num_dets]; + for d in 0..num_dets { + let mut prod = 1.0; + for &m in &det_to_mechs[d] { + prod *= 1.0 - 2.0 * q[m]; + } + let fitted = (1.0 - prod) / 2.0; + residuals[d] = (fitted - target_marginals[d]).abs(); + } + + let fitted: Vec = mechanisms + .iter() + .zip(q.iter()) + .map(|(mech, &prob)| DemMechanism { + probability: prob, + detectors: mech.detectors.clone(), + observables: mech.observables.clone(), + }) + .collect(); + + (fitted, residuals) +} + +/// Format fitted mechanisms as a standard DEM string. +#[must_use] +pub fn mechanisms_to_dem_string(mechanisms: &[DemMechanism]) -> String { + let mut lines = Vec::new(); + for mech in mechanisms { + if mech.probability > 1e-15 { + let mut tokens = Vec::new(); + for &d in &mech.detectors { + tokens.push(format!("D{d}")); + } + for &o in &mech.observables { + tokens.push(format!("L{o}")); + } + if !tokens.is_empty() { + lines.push(format!( + "error({:.10e}) {}", + mech.probability, + tokens.join(" ") + )); + } + } + } + lines.join("\n") +} + +// --- Internal helpers --- + +/// Iterate over all k-combinations of `items`, calling `f` with each sorted combination. +fn for_each_combination(items: &[u32], k: usize, mut f: impl FnMut(&[u32])) { + if k == 0 || items.len() < k { + return; + } + let mut combo = vec![0u32; k]; + combination_recurse(items, k, 0, 0, &mut combo, &mut f); +} + +fn combination_recurse( + items: &[u32], + k: usize, + start: usize, + depth: usize, + combo: &mut [u32], + f: &mut impl FnMut(&[u32]), +) { + if depth == k { + f(&combo[..k]); + return; + } + let remaining = k - depth; + if start + remaining > items.len() { + return; + } + for i in start..=items.len() - remaining { + combo[depth] = items[i]; + combination_recurse(items, k, i + 1, depth + 1, combo, f); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_flip_matrix_single_detector() { + // 100 shots, detector 0 fires 30 times + let fired: Vec> = (0..100) + .map(|i| if i < 30 { vec![0] } else { vec![] }) + .collect(); + let m = flip_matrix_from_fired(&fired, 2); + assert!((m[0] - 0.3).abs() < 1e-10); // M[0,0] = 0.3 + assert!(m[1].abs() < 1e-10); // M[0,1] = 0 + assert!(m[3].abs() < 1e-10); // M[1,1] = 0 + } + + #[test] + fn test_flip_matrix_correlated_pair() { + // 100 shots, detectors 0 and 1 always fire together + let fired: Vec> = (0..100) + .map(|i| if i < 20 { vec![0, 1] } else { vec![] }) + .collect(); + let m = flip_matrix_from_fired(&fired, 2); + assert!((m[0] - 0.2).abs() < 1e-10); // M[0,0] + assert!((m[3] - 0.2).abs() < 1e-10); // M[1,1] + assert!((m[1] - 0.1).abs() < 1e-10); // M[0,1] = 0.5 * 0.2 + assert!((m[2] - 0.1).abs() < 1e-10); // M[1,0] = 0.5 * 0.2 + } + + #[test] + fn test_k_body_rates_basic() { + let fired = vec![vec![0, 1, 2], vec![0, 1], vec![0], vec![]]; + let rates = k_body_rates(&fired, 3, 3); + assert!((rates[&vec![0]] - 0.75).abs() < 1e-10); + assert!((rates[&vec![1]] - 0.5).abs() < 1e-10); + assert!((rates[&vec![0, 1]] - 0.5).abs() < 1e-10); + assert!((rates[&vec![0, 1, 2]] - 0.25).abs() < 1e-10); + } + + #[test] + fn test_compare_k_body_basic() { + let mut sim = BTreeMap::new(); + sim.insert(vec![0], 0.1); + sim.insert(vec![1], 0.2); + sim.insert(vec![0, 1], 0.01); + + let mut dem = BTreeMap::new(); + dem.insert(vec![0], 0.1); + dem.insert(vec![1], 0.2); + dem.insert(vec![0, 1], 0.012); + + let result = compare_k_body(&sim, &dem, 0.005); + // 1-body: exact match + assert!(result[&1].0 < 1e-10); + // 2-body: 20% relative error on (0,1) + assert!((result[&2].0 - 0.2).abs() < 1e-10); + } + + #[test] + fn test_by_round_splits_correctly() { + // 4 detectors, 2 per round -> 2 rounds + let fired = vec![vec![0, 2], vec![1, 3]]; + let mats = flip_matrices_by_round(&fired, 4, 2); + assert_eq!(mats.len(), 2); + // Round 0: det 0 in shot 0, det 1 in shot 1 + assert!((mats[0][0] - 0.5).abs() < 1e-10); // M[0,0] + assert!((mats[0][3] - 0.5).abs() < 1e-10); // M[1,1] + // Round 1: det 0(=global 2) in shot 0, det 1(=global 3) in shot 1 + assert!((mats[1][0] - 0.5).abs() < 1e-10); + assert!((mats[1][3] - 0.5).abs() < 1e-10); + } + + #[test] + fn test_fit_dem_to_marginals_exact() { + // Two mechanisms: M0 flips {D0}, M1 flips {D0, D1} + // Target: P(D0) = 0.15, P(D1) = 0.05 + let mechs = vec![ + DemMechanism { + probability: 0.1, + detectors: vec![0], + observables: vec![], + }, + DemMechanism { + probability: 0.05, + detectors: vec![0, 1], + observables: vec![], + }, + ]; + let target = vec![0.15, 0.05]; + let (fitted, residuals) = fit_dem_to_marginals(&mechs, &target, 100, 1e-12); + + // M1 flips only D1, so q1 must satisfy (1-2*q1)/2 ≈ 0.05 → q1 ≈ 0.05 + // Then q0 must satisfy 1/2(1-(1-2*q0)(1-2*q1)) = 0.15 + assert!(residuals[0] < 1e-6, "D0 residual: {}", residuals[0]); + assert!(residuals[1] < 1e-6, "D1 residual: {}", residuals[1]); + assert!(fitted[1].probability > 0.04 && fitted[1].probability < 0.06); + } + + #[test] + fn test_fit_dem_preserves_structure() { + let mechs = vec![ + DemMechanism { + probability: 0.01, + detectors: vec![0], + observables: vec![0], + }, + DemMechanism { + probability: 0.02, + detectors: vec![1], + observables: vec![], + }, + ]; + let target = vec![0.05, 0.08]; + let (fitted, _) = fit_dem_to_marginals(&mechs, &target, 100, 1e-12); + + // Structure preserved + assert_eq!(fitted[0].detectors, vec![0]); + assert_eq!(fitted[0].observables, vec![0]); + assert_eq!(fitted[1].detectors, vec![1]); + } + + #[test] + fn test_mechanisms_to_dem_string() { + let mechs = vec![ + DemMechanism { + probability: 0.01, + detectors: vec![0, 1], + observables: vec![], + }, + DemMechanism { + probability: 0.001, + detectors: vec![2], + observables: vec![0], + }, + ]; + let s = mechanisms_to_dem_string(&mechs); + assert!(s.contains("D0 D1")); + assert!(s.contains("D2 L0")); + } +} diff --git a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs index 50b81940f..59d79fd0a 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder.rs @@ -12,14 +12,13 @@ //! Detector Error Model (DEM) generation from fault influence maps. //! -//! This module provides Rust-native DEM generation that produces output -//! compatible with Stim's format. It uses the per-qubit fault model for -//! accurate depolarizing noise analysis. +//! This module provides Rust-native DEM generation in standard DEM text format. +//! It uses the per-qubit fault model for accurate depolarizing noise analysis. //! //! # Architecture //! //! The DEM builder takes a [`DagFaultInfluenceMap`] (which maps fault locations -//! to their effects on measurements) and detector/observable metadata to produce +//! to their effects on measurements) and detector/DEM-output metadata to produce //! a complete DEM. //! //! # Example @@ -39,7 +38,7 @@ //! .with_observables_json("[]")? //! .build(); //! -//! // Output in Stim format (non-decomposed). +//! // Output in standard DEM format (non-decomposed). //! let _ = dem.to_string(); //! # Ok(()) //! # } @@ -47,7 +46,7 @@ //! //! # Error Decomposition //! -//! When using `to_stim_format_decomposed()`, hyperedge errors (affecting 3+ +//! 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. @@ -64,7 +63,7 @@ //! Pauli combinations (IX, IY, IZ, XI, ..., ZZ) are considered. //! //! - **XOR effect combining**: Correlated errors are properly combined -//! by XOR-ing detector/observable effects. +//! by XOR-ing detector/DEM-output effects. //! //! - **Independent probability combination**: When the same fault mechanism //! is triggered by multiple error sources, probabilities are combined @@ -72,50 +71,35 @@ //! //! # Measurement Noise Model (MNM) //! -//! In addition to the DEM, this module provides a Measurement Noise Model (MNM) -//! for fast approximate sampling. Unlike the DEM which maps to detectors, the -//! MNM maps directly to raw measurement effects. -//! -//! ``` -//! use pecos_qec::fault_tolerance::dem_builder::MemBuilder; -//! use pecos_qec::fault_tolerance::propagator::DagFaultInfluenceMap; -//! use rand::SeedableRng; -//! use rand::rngs::StdRng; -//! -//! // Normally `influence_map` comes from `DagFaultAnalyzer::build_influence_map()`; -//! // here we use an empty map to keep the doctest self-contained. -//! let influence_map = DagFaultInfluenceMap::with_capacity(0); -//! -//! let mnm = MemBuilder::new(&influence_map) -//! .with_noise(0.01, 0.01, 0.01, 0.01) -//! .build(); -//! -//! let mut rng = StdRng::seed_from_u64(0); -//! let _outcomes = mnm.sample(&mut rng); -//! ``` -//! -//! The MNM aggregates fault locations by their measurement effects (which -//! measurements flip together), enabling faster sampling with fewer random -//! draws compared to per-fault-location sampling. +//! The [`DemSampler`] provides both raw measurement and detector-event +//! output from a single mechanism engine. It replaces the former `MemBuilder` +//! (measurement-level) and `DemSamplerBuilder` (detector-level) paths with a +//! unified interface that validates detector definitions at build time. mod builder; mod dem_sampler; mod equivalence; mod mem_builder; +pub(crate) mod sampler; mod types; pub use builder::{DemBuilder, DemBuilderError}; -pub use dem_sampler::{DemSampler, DemSamplerBuilder, SamplingStatistics}; +pub use dem_sampler::{SamplingEngine, SamplingStatistics}; pub use equivalence::{ ComparisonDetails, ComparisonMethod, DemParseError, EffectKey, EquivalenceResult, MechanismComponent, ParsedDem, ParsedMechanism, ProbabilityMismatch, compare_dems_exact, compare_dems_statistical, verify_dem_equivalence, }; pub use mem_builder::MemBuilder; +pub use sampler::{ + DemSampler, DemSamplerBuilder, DetectorValidationError, DualSampleResult, OutputMode, + SamplerLabels, +}; pub use types::{ ContributionEffectSummary, ContributionRenderRecord, ContributionRenderStrategy, - ContributionRenderSummary, DecomposedFault, DetectorDef, DetectorErrorModel, - DirectSourceFamily, FaultContribution, FaultMechanism, FaultSourceType, LogicalObservable, - MeasurementMechanism, MeasurementNoiseModel, NoiseConfig, TwoDetectorDirectRenderPolicy, - combine_probabilities, record_offset_to_absolute_index, + 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, + 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 0a4db2452..ae792f1b1 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/builder.rs @@ -13,12 +13,13 @@ //! DEM (Detector Error Model) builder implementation. //! //! This module provides the main builder for constructing DEMs from fault -//! influence maps and detector/observable metadata. +//! influence maps and detector/DEM-output metadata. use super::types::{ - DetectorDef, DetectorErrorModel, DirectSourceComponents, FaultMechanism, LogicalObservable, - NoiseConfig, SourceMetadata, record_offset_to_absolute_index, + DemOutput, DetectorDef, DetectorErrorModel, DirectSourceComponents, FaultMechanism, + NoiseConfig, PerGateTypeNoise, SourceMetadata, record_offset_to_absolute_index, }; +use crate::fault_tolerance::propagator::dag::DagSpacetimeLocation; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; use pecos_core::gate_type::GateType; use smallvec::SmallVec; @@ -49,39 +50,56 @@ struct ParsedObservable { /// Builder for Detector Error Models (DEMs). /// -/// Constructs a DEM from a fault influence map and detector/observable metadata. -/// Uses the per-qubit fault model for accurate depolarizing noise analysis. +/// # Simple API (recommended) /// -/// # Example +/// For most use cases, use the one-liner: /// /// ``` /// use pecos_qec::fault_tolerance::dem_builder::DemBuilder; -/// use pecos_qec::fault_tolerance::propagator::DagFaultInfluenceMap; +/// use pecos_quantum::DagCircuit; /// -/// # fn main() -> Result<(), Box> { -/// let influence_map = DagFaultInfluenceMap::with_capacity(0); -/// let detectors_json = "[]"; -/// let observables_json = "[]"; +/// // Build DEM from circuit + noise (reads detectors from circuit metadata) +/// let dag = DagCircuit::new(); +/// let dem = DemBuilder::from_circuit(&dag, 0.001, 0.01, 0.001, 0.001); +/// assert_eq!(dem.num_detectors(), 0); +/// ``` +/// +/// Also works with `TickCircuit`: +/// +/// ``` +/// use pecos_qec::fault_tolerance::dem_builder::DemBuilder; +/// use pecos_quantum::TickCircuit; +/// +/// let tc = TickCircuit::new(); +/// let dem = DemBuilder::from_tick_circuit(&tc, 0.001, 0.01, 0.001, 0.001); +/// assert_eq!(dem.num_detectors(), 0); +/// ``` +/// +/// # Advanced API /// +/// For custom influence maps, non-standard noise, or manual detector +/// definitions, use the step-by-step builder: +/// +/// ```no_run +/// # use pecos_qec::fault_tolerance::dem_builder::DemBuilder; +/// # use pecos_qec::fault_tolerance::propagator::DagFaultInfluenceMap; +/// # let influence_map = DagFaultInfluenceMap::with_capacity(0); /// let dem = DemBuilder::new(&influence_map) /// .with_noise(0.01, 0.01, 0.01, 0.01) -/// .with_detectors_json(detectors_json)? -/// .with_observables_json(observables_json)? +/// .with_detectors_json("[]").unwrap() /// .build(); -/// -/// // Non-decomposed output (matches Stim's decompose_errors=False) -/// let _ = dem.to_string(); -/// -/// // Decomposed output (matches Stim's decompose_errors=True) -/// let _ = dem.to_string_decomposed(); -/// # Ok(()) -/// # } /// ``` pub struct DemBuilder<'a> { /// Reference to the fault influence map. influence_map: &'a DagFaultInfluenceMap, - /// Noise configuration. + /// Uniform-depolarizing noise configuration. When `per_gate` is also + /// set, its per-qubit / per-Pauli overrides take precedence; this + /// `NoiseConfig` still seeds measurement/prep scalars. noise: NoiseConfig, + /// Optional per-gate-type per-Pauli noise spec. Mirrors the + /// `DemSamplerBuilder` path so DEM text export reflects the same + /// asymmetric noise structure that the sampler does. + per_gate: Option, /// Parsed detector definitions. detectors: Vec, /// Parsed observable definitions. @@ -94,12 +112,56 @@ pub struct DemBuilder<'a> { } impl<'a> DemBuilder<'a> { + /// Build a `DetectorErrorModel` directly from a circuit and noise. + /// + /// One-liner for the common case. Reads detector/DEM output definitions + /// from circuit metadata (`"detectors"`, `"observables"` attributes). + /// + /// ``` + /// use pecos_qec::fault_tolerance::dem_builder::DemBuilder; + /// use pecos_quantum::DagCircuit; + /// + /// let dag = DagCircuit::new(); + /// let dem = DemBuilder::from_circuit(&dag, 0.001, 0.01, 0.001, 0.001); + /// assert_eq!(dem.num_detectors(), 0); + /// ``` + /// Build a `DetectorErrorModel` directly from a `DagCircuit` and noise. + /// + /// One-liner for the common case. Reads detector/DEM output definitions + /// from circuit metadata. + #[must_use] + pub fn from_circuit( + circuit: &pecos_quantum::DagCircuit, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + ) -> DetectorErrorModel { + build_dem_from_circuit(circuit, p1, p2, p_meas, p_prep) + } + + /// Build a `DetectorErrorModel` from a `TickCircuit` and noise. + /// + /// Converts to `DagCircuit` internally. + #[must_use] + pub fn from_tick_circuit( + circuit: &pecos_quantum::TickCircuit, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + ) -> DetectorErrorModel { + let dag = pecos_quantum::DagCircuit::from(circuit); + build_dem_from_circuit(&dag, p1, p2, p_meas, p_prep) + } + /// Creates a new DEM builder from a fault influence map. #[must_use] pub fn new(influence_map: &'a DagFaultInfluenceMap) -> Self { Self { influence_map, noise: NoiseConfig::default(), + per_gate: None, detectors: Vec::new(), observables: Vec::new(), num_measurements: influence_map.measurements.len(), @@ -107,13 +169,140 @@ impl<'a> DemBuilder<'a> { } } - /// Sets the noise configuration. + /// 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 { + self.noise = NoiseConfig::new(p1, p2, p_meas, p_prep); + self + } + + /// Sets the full noise configuration (supports custom weights, T1/T2, idle). + #[must_use] + pub fn with_noise_config(mut self, noise: NoiseConfig) -> Self { + self.noise = noise; + self + } + + /// Attach per-gate-type per-Pauli noise. When present, overrides + /// [`Self::with_noise`] scalars for gate types in the spec's maps. + /// Mirrors + /// [`crate::fault_tolerance::dem_builder::DemSamplerBuilder::with_per_gate_noise`] + /// so the DEM text output reflects the same noise structure. #[must_use] - pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_init: f64) -> Self { - self.noise = NoiseConfig::new(p1, p2, p_meas, p_init); + pub fn with_per_gate_noise(mut self, cfg: PerGateTypeNoise) -> Self { + self.noise.p_meas = cfg.p_meas; + self.noise.p_prep = cfg.p_init; + self.per_gate = Some(cfg); self } + /// Resolve preparation X-error rate at a specific location. + fn init_rate_for_loc(&self, loc: &DagSpacetimeLocation) -> f64 { + if let Some(pg) = &self.per_gate + && let Some(q) = loc.qubits.first() + { + return pg.init_rate_on(*q); + } + self.noise.p_prep + } + + /// Resolve measurement X-flip rate at a specific location. + fn measurement_rate_for_loc(&self, loc: &DagSpacetimeLocation) -> f64 { + if let Some(pg) = &self.per_gate + && let Some(q) = loc.qubits.first() + { + return pg.measurement_rate_on(*q); + } + self.noise.p_meas + } + + /// Resolve `[rate_X, rate_Y, rate_Z]` for a 1Q gate location. + fn rates_1q_for_loc(&self, loc: &DagSpacetimeLocation) -> [f64; 3] { + if let Some(pg) = &self.per_gate { + if let Some(q) = loc.qubits.first() { + return [ + pg.rate_1q_on(loc.gate_type, *q, 0), + pg.rate_1q_on(loc.gate_type, *q, 1), + pg.rate_1q_on(loc.gate_type, *q, 2), + ]; + } + return [ + pg.rate_1q(loc.gate_type, 0), + pg.rate_1q(loc.gate_type, 1), + pg.rate_1q(loc.gate_type, 2), + ]; + } + 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)), + ]; + } + let per = per_channel_probability(self.noise.p1, 3); + [per, per, per] + } + + /// Resolve `[rate_X, rate_Y, rate_Z]` for an explicit idle location. + fn idle_rates_for_loc(&self, loc: &DagSpacetimeLocation) -> [f64; 3] { + if let Some(pg) = &self.per_gate { + let explicit_rates = loc + .qubits + .first() + .and_then(|q| pg.explicit_1q_rates_on(GateType::Idle, *q)) + .or_else(|| pg.explicit_1q_rates(GateType::Idle)); + if let Some(rates) = explicit_rates { + return rates; + } + if pg.base.uses_dedicated_idle_noise() { + #[allow(clippy::cast_precision_loss)] + let duration = loc.idle_duration.max(1) as f64; + let probs = pg.base.idle_pauli_probs(duration); + return [probs.px, probs.py, probs.pz]; + } + return [0.0; 3]; + } + + if self.noise.uses_dedicated_idle_noise() { + #[allow(clippy::cast_precision_loss)] + let duration = loc.idle_duration.max(1) as f64; + let probs = self.noise.idle_pauli_probs(duration); + return [probs.px, probs.py, probs.pz]; + } + [0.0; 3] + } + + /// Resolve the 15-entry 2Q per-Pauli-pair rate array for a gate + /// spanning two fault locations. + fn rates_2q_for_locs( + &self, + loc1: &DagSpacetimeLocation, + loc2: &DagSpacetimeLocation, + ) -> [f64; 15] { + if let Some(pg) = &self.per_gate { + let gate = loc1.gate_type; + let mut qubits = loc1 + .qubits + .iter() + .copied() + .chain(loc2.qubits.iter().copied()); + if let (Some(qc), Some(qt)) = (qubits.next(), qubits.next()) { + return std::array::from_fn(|i| pg.rate_2q_on(gate, qc, qt, i)); + } + return std::array::from_fn(|i| pg.rate_2q(gate, i)); + } + if let Some(weights) = &self.noise.p2_weights { + return std::array::from_fn(|idx| { + let flat = idx + 1; + let p1 = flat / 4; + let p2 = flat % 4; + self.noise.p2 * weights.weight_for(&pauli_pair_for_weight(p1, p2)) + }); + } + [per_channel_probability(self.noise.p2, 15); 15] + } + /// Sets the number of measurements (used for record offset calculation). #[must_use] pub fn with_num_measurements(mut self, num: usize) -> Self { @@ -129,6 +318,13 @@ impl<'a> DemBuilder<'a> { /// indices (which may use a different order based on DAG topology). /// /// # Arguments + /// Set the measurement order for legacy circuits without `MeasId` on gates. + /// + /// **Not needed for circuits built with `TickCircuit.mz()`** — the `MeasId` + /// values on gates ensure correct ordering automatically. + /// + /// Only use this for circuits where MZ gates lack `meas_ids` (e.g., + /// circuits imported from external formats without measurement IDs). /// /// * `order` - List of qubit indices in measurement execution order. /// `order[i]` is the qubit measured at `TickCircuit` measurement index `i`. @@ -160,15 +356,10 @@ impl<'a> DemBuilder<'a> { /// Parses and sets observable definitions from JSON. /// - /// Each object accepts either `"id"` or `"observable_id"` as the identifier key. + /// Tracked Paulis are carried by the influence map; this helper is only + /// for observable metadata. /// - /// Expected format: - /// ```json - /// [ - /// {"id": 0, "records": [-1, -3, -5]}, - /// {"observable_id": 1, "records": [-2]} - /// ] - /// ``` + /// Each object accepts either `"id"` or `"observable_id"` as the identifier key. /// /// # Errors /// @@ -178,6 +369,21 @@ impl<'a> DemBuilder<'a> { Ok(self) } + /// Sets observable definitions from measurement-record offsets. + #[must_use] + pub fn with_observable_records(mut self, records: Vec>) -> Self { + self.observables = records + .into_iter() + .enumerate() + .map(|(id, records)| ParsedObservable { + #[allow(clippy::cast_possible_truncation)] // observable count fits in u32 + id: id as u32, + records, + }) + .collect(); + self + } + /// Builds the Detector Error Model with source tracking. /// /// This performs fault propagation analysis and tracks error sources (X/Z vs Y) @@ -186,6 +392,9 @@ impl<'a> DemBuilder<'a> { /// Use `dem.to_string()` or `dem.to_string_decomposed()` for output. #[must_use] pub fn build(&self) -> DetectorErrorModel { + let num_influence_dem_outputs = self + .num_influence_dem_outputs() + .max(self.influence_map.dem_output_metadata.len()); let mut dem = DetectorErrorModel::with_capacity(self.detectors.len(), self.observables.len()); @@ -199,13 +408,42 @@ impl<'a> DemBuilder<'a> { dem.add_detector(def); } - // Add observable definitions + // Add non-detector outputs carried directly by the influence map. + // Metadata-bearing outputs use separate compact ID spaces for standard + // observables and PECOS tracked Paulis. + if self.influence_map.dem_output_metadata.is_empty() { + for dem_output_idx in 0..num_influence_dem_outputs { + #[allow(clippy::cast_possible_truncation)] // DEM output count fits in u32 + dem.add_observable(DemOutput::new(dem_output_idx as u32)); + } + } else { + for (internal_idx, metadata) in + self.influence_map.dem_output_metadata.iter().enumerate() + { + #[allow(clippy::cast_possible_truncation)] // DEM output count fits in u32 + let internal_id = internal_idx as u32; + if let Some(dem_output_id) = self + .influence_map + .tracked_pauli_id_for_internal_dem_output(internal_id) + { + dem.add_tracked_pauli(DemOutput::from_metadata(dem_output_id, metadata)); + } else if let Some(dem_output_id) = self + .influence_map + .observable_id_for_internal_dem_output(internal_id) + { + dem.add_observable(DemOutput::from_metadata(dem_output_id, metadata)); + } + } + } + + // Add observable definitions in the standard `L` namespace. + // Observable IDs are not shifted by tracked Paulis. for obs in &self.observables { - let def = LogicalObservable::new(obs.id).with_records(obs.records.iter().copied()); + let def = DemOutput::new(obs.id).with_records(obs.records.iter().copied()); dem.add_observable(def); } - // Build measurement -> detector/observable mappings + // Build measurement -> detector/DEM-output mappings let (meas_to_detectors, meas_to_observables) = self.build_measurement_mappings(); // Process all fault locations with source tracking @@ -218,6 +456,13 @@ impl<'a> DemBuilder<'a> { dem } + fn num_influence_dem_outputs(&self) -> usize { + self.influence_map + .influences + .max_dem_output_index() + .map_or(0, |idx| idx + 1) + } + /// Processes fault locations with source tracking. /// /// This version uses `add_direct_contribution` and `add_y_decomposed_contribution` @@ -235,7 +480,9 @@ impl<'a> DemBuilder<'a> { for (loc_idx, loc) in locations.iter().enumerate() { match loc.gate_type { - GateType::PZ | GateType::QAlloc if self.noise.p_init > 0.0 && !loc.before => { + GateType::PZ | GateType::QAlloc + if !loc.before && self.init_rate_for_loc(loc) > 0.0 => + { self.process_prep_fault_source_tracked( loc_idx, dem, @@ -243,7 +490,9 @@ impl<'a> DemBuilder<'a> { meas_to_observables, ); } - GateType::MZ | GateType::MeasureFree if self.noise.p_meas > 0.0 && loc.before => { + GateType::MZ | GateType::MeasureFree + if loc.before && self.measurement_rate_for_loc(loc) > 0.0 => + { self.process_meas_fault_source_tracked( loc_idx, dem, @@ -254,6 +503,12 @@ impl<'a> DemBuilder<'a> { GateType::CX | GateType::CZ | GateType::CY + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg | GateType::SWAP | GateType::RXX | GateType::RYY @@ -263,6 +518,8 @@ impl<'a> DemBuilder<'a> { cx_groups.entry(loc.node).or_default().push(loc_idx); } GateType::H + | GateType::F + | GateType::Fdg | GateType::SZ | GateType::SZdg | GateType::SX @@ -279,26 +536,49 @@ impl<'a> DemBuilder<'a> { | GateType::RZ | GateType::U | GateType::R1XY - if self.noise.p1 > 0.0 && !loc.before => + if !loc.before => { - self.process_single_qubit_fault_source_tracked( - loc_idx, - dem, - meas_to_detectors, - meas_to_observables, - ); + let rates = self.rates_1q_for_loc(loc); + if rates.iter().any(|r| *r > 0.0) { + self.process_single_qubit_fault_source_tracked( + loc_idx, + rates, + dem, + meas_to_detectors, + meas_to_observables, + ); + } + } + GateType::Idle if !loc.before => { + let rates = self.idle_rates_for_loc(loc); + if rates.iter().any(|r| *r > 0.0) { + self.process_single_qubit_fault_source_tracked( + loc_idx, + rates, + dem, + meas_to_detectors, + meas_to_observables, + ); + } } _ => {} } } - // Process two-qubit gates - if self.noise.p2 > 0.0 { - for (_, loc_indices) in cx_groups { - if loc_indices.len() == 2 { + // 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( - loc_indices[0], - loc_indices[1], + pair[0], + pair[1], + rates, dem, meas_to_detectors, meas_to_observables, @@ -316,19 +596,16 @@ impl<'a> DemBuilder<'a> { meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, ) { + let loc = &self.influence_map.locations[loc_idx]; + let p = self.init_rate_for_loc(loc); // For Z-basis prep, X error matters - this is a direct source 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, - self.noise.p_init, - SourceMetadata::new( - &[loc_idx], - &[Pauli::X], - &[self.influence_map.locations[loc_idx].gate_type], - &[self.influence_map.locations[loc_idx].before], - ), + p, + SourceMetadata::new(&[loc_idx], &[Pauli::X], &[loc.gate_type], &[loc.before]), ); } } @@ -341,32 +618,31 @@ impl<'a> DemBuilder<'a> { meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, ) { + let loc = &self.influence_map.locations[loc_idx]; + let p = self.measurement_rate_for_loc(loc); // Measurement error is a bit flip (X error) - this is a direct source 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, - self.noise.p_meas, - SourceMetadata::new( - &[loc_idx], - &[Pauli::X], - &[self.influence_map.locations[loc_idx].gate_type], - &[self.influence_map.locations[loc_idx].before], - ), + p, + SourceMetadata::new(&[loc_idx], &[Pauli::X], &[loc.gate_type], &[loc.before]), ); } } /// 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( &self, loc_idx: usize, + rates: [f64; 3], dem: &mut DetectorErrorModel, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, ) { - let prob = per_channel_probability(self.noise.p1, 3); + let [rate_x, rate_y, rate_z] = rates; let x_effect = self.compute_mechanism(loc_idx, Pauli::X, meas_to_detectors, meas_to_observables); @@ -374,10 +650,10 @@ impl<'a> DemBuilder<'a> { self.compute_mechanism(loc_idx, Pauli::Z, meas_to_detectors, meas_to_observables); // X error: direct source - if !x_effect.is_empty() { + if rate_x > 0.0 && !x_effect.is_empty() { dem.add_direct_contribution_with_source( x_effect.clone(), - prob, + rate_x, SourceMetadata::new( &[loc_idx], &[Pauli::X], @@ -388,10 +664,10 @@ impl<'a> DemBuilder<'a> { } // Z error: direct source - if !z_effect.is_empty() { + if rate_z > 0.0 && !z_effect.is_empty() { dem.add_direct_contribution_with_source( z_effect.clone(), - prob, + rate_z, SourceMetadata::new( &[loc_idx], &[Pauli::Z], @@ -402,19 +678,13 @@ impl<'a> DemBuilder<'a> { } // Y error: Y = XZ, so effect is XOR of X and Z effects - // Handle all cases: - // 1. Both non-empty and different: decomposable Y = X ^ Z - // 2. X non-empty, Z empty: Y has same effect as X (direct) - // 3. X empty, Z non-empty: Y has same effect as Z (direct) - // 4. Both non-empty and equal: Y effect is empty (X XOR X = nothing) let y_effect = x_effect.xor(&z_effect); - if !y_effect.is_empty() { + if rate_y > 0.0 && !y_effect.is_empty() { if !x_effect.is_empty() && !z_effect.is_empty() { - // Both non-empty, so Y is decomposable as X ^ Z dem.add_y_decomposed_contribution_with_source( &x_effect, &z_effect, - prob, + rate_y, SourceMetadata::new( &[loc_idx], &[Pauli::Y], @@ -426,7 +696,7 @@ impl<'a> DemBuilder<'a> { // One is empty, so Y has same effect as the non-empty one (direct source) dem.add_direct_contribution_with_source( y_effect, - prob, + rate_y, SourceMetadata::new( &[loc_idx], &[Pauli::Y], @@ -439,15 +709,17 @@ impl<'a> DemBuilder<'a> { } /// Processes a two-qubit gate fault with source tracking and intra-channel decomposition. + /// `rates` is the 15-entry array in `PAULI_2Q_ORDER` order -- zero entries + /// are skipped. fn process_two_qubit_fault_source_tracked( &self, loc1: usize, loc2: usize, + rates: [f64; 15], dem: &mut DetectorErrorModel, meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, ) { - let prob = per_channel_probability(self.noise.p2, 15); let loc1_meta = &self.influence_map.locations[loc1]; let loc2_meta = &self.influence_map.locations[loc2]; @@ -489,16 +761,23 @@ impl<'a> DemBuilder<'a> { 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; + } + // 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 logicals + // - 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.logicals.is_empty() + && effect.dem_outputs.is_empty() && !e1.is_empty() && !e2.is_empty() && e1.num_detectors() <= 2 @@ -547,7 +826,7 @@ impl<'a> DemBuilder<'a> { } } - /// Builds mappings from measurement indices to detector/observable IDs. + /// Builds mappings from measurement indices to detector/DEM-output IDs. /// /// When `measurement_order` is provided, this properly maps between /// `TickCircuit` measurement indices (used in record offsets) and influence @@ -559,6 +838,7 @@ impl<'a> DemBuilder<'a> { fn build_measurement_mappings(&self) -> (BTreeMap>, BTreeMap>) { let mut meas_to_detectors: BTreeMap> = BTreeMap::new(); let mut meas_to_observables: BTreeMap> = BTreeMap::new(); + let influence_observable_ids = self.influence_map.observable_ids(); // Build a mapping from (qubit, occurrence_index) to influence_map_index // This handles multi-round circuits where the same qubit is measured multiple times @@ -622,6 +902,9 @@ impl<'a> DemBuilder<'a> { } for obs in &self.observables { + if influence_observable_ids.contains(&obs.id) { + continue; + } for &rec in &obs.records { if let Some(tc_meas_idx) = record_offset_to_absolute_index(self.num_measurements, rec) @@ -646,7 +929,7 @@ impl<'a> DemBuilder<'a> { meas_to_detectors: &BTreeMap>, meas_to_observables: &BTreeMap>, ) -> FaultMechanism { - // Get the Rust detector indices that this fault flips + // Get the measurement indices that this fault flips let rust_dets = self .influence_map .get_detector_indices(loc_idx, pauli.as_u8()); @@ -654,6 +937,20 @@ impl<'a> DemBuilder<'a> { // 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()) + { + 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()) + { + xor_toggle_2(&mut triggered_tracked_paulis, tracked_pauli_idx); + } for &rust_det in rust_dets { let meas_idx = rust_det as usize; @@ -676,8 +973,13 @@ impl<'a> DemBuilder<'a> { // Sort for canonical form triggered_dets.sort_unstable(); triggered_obs.sort_unstable(); + triggered_tracked_paulis.sort_unstable(); - FaultMechanism::from_sorted(triggered_dets, triggered_obs) + FaultMechanism::from_sorted_with_tracked_paulis( + triggered_dets, + triggered_obs, + triggered_tracked_paulis, + ) } } @@ -699,6 +1001,26 @@ fn xor_toggle_2(vec: &mut SmallVec<[u32; 2]>, value: u32) { } } +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) +} + /// Computes the per-error probability for independent error channels. /// /// For a depolarizing channel with total error probability `p` split among `n` @@ -949,6 +1271,120 @@ fn extract_records(json: &str) -> Vec { Vec::new() } +// ============================================================================ +// Convenience: build DEM from circuit (free function to handle lifetimes) +// ============================================================================ + +/// Build a `DetectorErrorModel` from a `DagCircuit` and noise parameters. +/// +/// 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, +) -> DetectorErrorModel { + use crate::fault_tolerance::influence_builder::InfluenceBuilder; + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_num::graph::Attribute; + + let mut influence_map = DagFaultAnalyzer::new(circuit).build_influence_map(); + let annotated_observable_records = observable_records_from_annotations(circuit, &influence_map); + let annotation_map = InfluenceBuilder::new(circuit) + .with_circuit_annotations(circuit) + .build(); + influence_map.merge_dem_outputs_from(&annotation_map); + + // Extract metadata before building (to avoid borrow issues) + let det_json = circuit.get_attr("detectors").and_then(|a| { + if let Attribute::String(s) = a { + Some(s.clone()) + } else { + None + } + }); + let obs_json = circuit.get_attr("observables").and_then(|a| { + if let Attribute::String(s) = a { + Some(s.clone()) + } else { + None + } + }); + let num_meas = circuit.get_attr("num_measurements").and_then(|a| { + if let Attribute::String(s) = a { + s.parse::().ok() + } else { + None + } + }); + + let builder = DemBuilder::new(&influence_map).with_noise(p1, p2, p_meas, p_prep); + + let builder = if let Some(ref dj) = det_json { + builder + .with_detectors_json(dj) + .unwrap_or_else(|_| DemBuilder::new(&influence_map).with_noise(p1, p2, p_meas, p_prep)) + } else { + builder + }; + + let builder = if let Some(ref oj) = obs_json { + builder + .with_observables_json(oj) + .unwrap_or_else(|_| DemBuilder::new(&influence_map).with_noise(p1, p2, p_meas, p_prep)) + } else if !annotated_observable_records.is_empty() { + builder.with_observable_records(annotated_observable_records) + } else { + builder + }; + + let builder = if let Some(n) = num_meas { + builder.with_num_measurements(n) + } else { + builder + }; + + builder.build() +} + +fn observable_records_from_annotations( + circuit: &pecos_quantum::DagCircuit, + influence_map: &DagFaultInfluenceMap, +) -> Vec> { + use pecos_quantum::AnnotationKind; + + let num_measurements = influence_map.measurements.len(); + if num_measurements == 0 { + return Vec::new(); + } + + let mut node_to_meas_idx: BTreeMap = BTreeMap::new(); + for (meas_idx, &(node, _qubit, _basis)) in influence_map.measurements.iter().enumerate() { + node_to_meas_idx.entry(node).or_insert(meas_idx); + } + + circuit + .observables() + .map(|ann| { + if let AnnotationKind::Observable { measurement_nodes } = &ann.kind { + measurement_nodes + .iter() + .filter_map(|node| node_to_meas_idx.get(node).copied()) + .map(|meas_idx| { + #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)] + { + meas_idx as i32 - num_measurements as i32 + } + }) + .collect() + } else { + Vec::new() + } + }) + .collect() +} + // ============================================================================ // Error Type // ============================================================================ @@ -974,6 +1410,497 @@ impl std::error::Error for DemBuilderError {} mod tests { use super::*; + #[test] + fn test_from_circuit_tracks_tracked_pauli() { + use pecos_core::pauli::X; + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0]); + circuit.h(&[0]); + circuit.tracked_pauli_labeled("x_check", X(0)); + + let dem = DemBuilder::from_circuit(&circuit, 0.03, 0.0, 0.0, 0.0); + + assert_eq!(dem.num_dem_outputs(), 0); + assert_eq!(dem.num_tracked_paulis(), 1); + assert_eq!(dem.num_observables(), 0); + assert_eq!( + dem.tracked_paulis()[0].kind, + Some(crate::fault_tolerance::DemOutputKind::TrackedPauli) + ); + assert_eq!(dem.tracked_paulis()[0].label.as_deref(), Some("x_check")); + assert_eq!( + dem.tracked_paulis()[0] + .pauli + .as_ref() + .unwrap() + .to_sparse_str(), + "+X0" + ); + assert!(!dem.to_string().contains("logical_observable")); + assert!(!dem.to_string().contains("TP0")); + let pecos_text = dem.to_pecos_string(); + assert!(pecos_text.contains("TP0")); + assert!(pecos_text.contains("pecos_tracked_pauli")); + } + + #[test] + fn test_tracked_pauli_and_observable_use_distinct_tracked_paulis() { + use pecos_core::pauli::Z; + use pecos_quantum::{Attribute, DagCircuit}; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0]); + circuit.tracked_pauli_labeled("z_check", Z(0)); + circuit.mz(&[0]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "observables", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + + let dem = DemBuilder::from_circuit(&circuit, 0.0, 0.0, 0.02, 0.03); + + assert_eq!(dem.num_dem_outputs(), 1); + assert_eq!(dem.num_tracked_paulis(), 1); + assert_eq!(dem.num_observables(), 1); + assert_eq!( + dem.dem_outputs()[0].kind, + Some(crate::fault_tolerance::DemOutputKind::Observable) + ); + assert_eq!(dem.tracked_paulis()[0].label.as_deref(), Some("z_check")); + let dem_str = dem.to_string(); + assert!(dem_str.contains("logical_observable L0")); + assert!(!dem_str.contains("logical_observable L1")); + assert!(!dem_str.contains("TP0")); + let pecos_text = dem.to_pecos_string(); + assert!(pecos_text.contains("TP0")); + assert!(pecos_text.contains("pecos_tracked_pauli")); + let summaries = dem.contribution_effect_summaries(); + assert!( + summaries + .iter() + .any(|summary| summary.effect.dem_outputs.as_slice() == [0]), + "observable should remain L0" + ); + assert!( + summaries + .iter() + .any(|summary| summary.effect.tracked_paulis.as_slice() == [0]), + "tracked Pauli should remain TP0" + ); + } + + #[test] + fn test_tick_dag_tick_dem_keeps_detector_observable_and_tracked_pauli_distinct() { + use pecos_core::pauli::X; + use pecos_quantum::{DagCircuit, TickCircuit}; + + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0, 1]); + circuit.tick().h(&[0]); + circuit.tracked_pauli_labeled("tracked_x0", X(0)); + circuit.tick().mz(&[0, 1]); + circuit.set_meta( + "num_measurements", + pecos_quantum::Attribute::String(circuit.num_measurements().to_string()), + ); + circuit + .add_detector_metadata(&[-2], None, Some("D0"), Some(0)) + .unwrap(); + circuit + .add_observable_metadata(&[-1], Some(0), Some("L0")) + .unwrap(); + let round_tripped = TickCircuit::from(&DagCircuit::from(&circuit)); + let dem = DemBuilder::from_tick_circuit(&round_tripped, 0.03, 0.0, 0.02, 0.0); + + assert_eq!(dem.num_detectors(), 1); + assert_eq!(dem.num_observables(), 1); + assert_eq!(dem.num_dem_outputs(), 1); + assert_eq!(dem.dem_outputs()[0].id, 0); + assert_eq!(dem.num_tracked_paulis(), 1); + assert_eq!(dem.tracked_paulis()[0].id, 0); + assert_eq!(dem.tracked_paulis()[0].label.as_deref(), Some("tracked_x0")); + assert_eq!( + dem.tracked_paulis()[0] + .pauli + .as_ref() + .unwrap() + .to_sparse_str(), + "+X0" + ); + + let standard_text = dem.to_string(); + assert!(standard_text.contains("logical_observable L0")); + assert!(!standard_text.contains("logical_observable L1")); + assert!(!standard_text.contains("pecos_tracked_pauli")); + + let pecos_text = dem.to_pecos_string(); + assert!(pecos_text.contains("pecos_observable")); + assert!(pecos_text.contains("pecos_tracked_pauli")); + + let summaries = dem.contribution_effect_summaries(); + assert!( + summaries + .iter() + .any(|summary| summary.effect.detectors.as_slice() == [0]), + "detector effects should survive Tick -> DAG -> Tick" + ); + assert!( + summaries + .iter() + .any(|summary| summary.effect.dem_outputs.as_slice() == [0]), + "observable effects should remain in L0" + ); + } + + #[test] + fn test_circuit_observable_annotation_is_not_double_counted() { + use pecos_quantum::DagCircuit; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0]); + let meas = circuit.mz(&[0]); + circuit.observable_labeled("obs0", &[meas[0]]); + + let dem = DemBuilder::from_circuit(&circuit, 0.0, 0.0, 1.0, 0.0); + + assert_eq!(dem.num_dem_outputs(), 1); + assert_eq!(dem.num_observables(), 1); + assert_eq!(dem.dem_outputs().len(), 1); + assert_eq!(dem.dem_outputs()[0].id, 0); + assert_eq!(dem.dem_outputs()[0].records.as_slice(), &[-1]); + assert_eq!(dem.dem_outputs()[0].label.as_deref(), Some("obs0")); + + let logical_observable_lines = dem + .to_string() + .lines() + .filter(|line| *line == "logical_observable L0") + .count(); + assert_eq!(logical_observable_lines, 1); + + let summaries = dem.contribution_effect_summaries(); + assert!( + summaries + .iter() + .any(|summary| summary.effect.dem_outputs.as_slice() == [0]), + "measurement fault should flip observable L0 once, not cancel" + ); + } + + #[test] + fn test_from_tick_circuit_tracks_face_gate_fault_sources() { + use pecos_core::QubitId; + use pecos_quantum::{Attribute, TickCircuit}; + + for gate_type in [GateType::F, GateType::Fdg] { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[QubitId(0)]); + match gate_type { + GateType::F => { + circuit.tick().f(&[QubitId(0)]); + } + GateType::Fdg => { + circuit.tick().fdg(&[QubitId(0)]); + } + _ => unreachable!(), + } + circuit.tick().mz(&[QubitId(0)]); + circuit.set_meta("num_measurements", Attribute::String("1".to_string())); + circuit.set_meta( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + circuit.set_meta("observables", Attribute::String("[]".to_string())); + + let dem = DemBuilder::from_tick_circuit(&circuit, 0.03, 0.0, 0.0, 0.0); + let contributions = dem.contributions_for_effect(&[0], &[]); + + assert!( + contributions + .iter() + .any(|contribution| contribution.source_gate_types.contains(&gate_type)), + "DEM should include a tracked {gate_type:?} fault source" + ); + } + } + + #[test] + fn test_fault_catalog_and_dem_cover_standard_clifford_gate_sources() { + use crate::fault_tolerance::fault_sampler::{ + FaultCatalog, StochasticNoiseParams, build_fault_catalog, + }; + use pecos_core::QubitId; + use pecos_quantum::{Attribute, TickCircuit}; + use std::collections::BTreeMap; + + fn set_meta(circuit: &mut TickCircuit, num_measurements: usize, detectors: &str) { + circuit.set_meta( + "num_measurements", + Attribute::String(num_measurements.to_string()), + ); + circuit.set_meta("detectors", Attribute::String(detectors.to_string())); + circuit.set_meta("observables", Attribute::String("[]".to_string())); + } + + fn add_1q_gate(circuit: &mut TickCircuit, gate_type: GateType) { + match gate_type { + GateType::X => { + circuit.tick().x(&[QubitId(0)]); + } + GateType::Y => { + circuit.tick().y(&[QubitId(0)]); + } + GateType::Z => { + circuit.tick().z(&[QubitId(0)]); + } + GateType::H => { + circuit.tick().h(&[QubitId(0)]); + } + GateType::F => { + circuit.tick().f(&[QubitId(0)]); + } + GateType::Fdg => { + circuit.tick().fdg(&[QubitId(0)]); + } + GateType::SX => { + circuit.tick().sx(&[QubitId(0)]); + } + GateType::SXdg => { + circuit.tick().sxdg(&[QubitId(0)]); + } + GateType::SY => { + circuit.tick().sy(&[QubitId(0)]); + } + GateType::SYdg => { + circuit.tick().sydg(&[QubitId(0)]); + } + GateType::SZ => { + circuit.tick().sz(&[QubitId(0)]); + } + GateType::SZdg => { + circuit.tick().szdg(&[QubitId(0)]); + } + _ => panic!("not a 1q standard Clifford gate: {gate_type:?}"), + } + } + + fn add_2q_gate(circuit: &mut TickCircuit, gate_type: GateType) { + let pair = &[(QubitId(0), QubitId(1))]; + match gate_type { + GateType::CX => { + circuit.tick().cx(pair); + } + GateType::CY => { + circuit.tick().cy(pair); + } + GateType::CZ => { + circuit.tick().cz(pair); + } + GateType::SXX => { + circuit.tick().sxx(pair); + } + GateType::SXXdg => { + circuit.tick().sxxdg(pair); + } + GateType::SYY => { + circuit.tick().syy(pair); + } + GateType::SYYdg => { + circuit.tick().syydg(pair); + } + GateType::SZZ => { + circuit.tick().szz(pair); + } + GateType::SZZdg => { + circuit.tick().szzdg(pair); + } + GateType::SWAP => { + circuit.tick().swap(pair); + } + _ => panic!("not a 2q standard Clifford gate: {gate_type:?}"), + } + } + + fn dem_has_source(dem: &DetectorErrorModel, gate_type: GateType) -> bool { + dem.contribution_render_records() + .iter() + .any(|record| record.contribution.source_gate_types.contains(&gate_type)) + } + + fn catalog_dem_channel_effect_probabilities( + catalog: &FaultCatalog, + ) -> BTreeMap<(Vec, Vec), f64> { + let mut by_effect = BTreeMap::new(); + for location in &catalog.locations { + if location.num_alternatives == 0 { + continue; + } + let num_alternatives = f64::from( + u32::try_from(location.num_alternatives) + .expect("fault alternative count fits in u32"), + ); + let per_channel_probability = + 1.0 - location.no_fault_probability.powf(1.0 / num_alternatives); + for fault in &location.faults { + if fault.affected_detectors.is_empty() && fault.affected_observables.is_empty() + { + continue; + } + let detectors: Vec = fault + .affected_detectors + .iter() + .map(|&det| u32::try_from(det).unwrap()) + .collect(); + let observables: Vec = fault + .affected_observables + .iter() + .map(|&obs| u32::try_from(obs).unwrap()) + .collect(); + *by_effect.entry((detectors, observables)).or_insert(0.0) += + per_channel_probability; + } + } + by_effect + } + + fn dem_effect_probabilities( + dem: &DetectorErrorModel, + ) -> BTreeMap<(Vec, Vec), f64> { + dem.contribution_effect_summaries() + .into_iter() + .filter(|summary| { + !summary.effect.detectors.is_empty() || !summary.effect.dem_outputs.is_empty() + }) + .map(|summary| { + ( + ( + summary.effect.detectors.into_iter().collect(), + summary.effect.dem_outputs.into_iter().collect(), + ), + summary.total_probability, + ) + }) + .collect() + } + + fn assert_catalog_dem_probabilities_match( + catalog: &FaultCatalog, + dem: &DetectorErrorModel, + gate_type: GateType, + ) { + let catalog_probs = catalog_dem_channel_effect_probabilities(catalog); + let dem_probs = dem_effect_probabilities(dem); + assert_eq!( + catalog_probs.keys().collect::>(), + dem_probs.keys().collect::>(), + "{gate_type:?} should produce the same non-empty effects in the fault catalog and DEM" + ); + for (effect, catalog_probability) in catalog_probs { + let dem_probability = dem_probs[&effect]; + assert!( + (catalog_probability - dem_probability).abs() < 1e-12, + "{gate_type:?} effect {effect:?}: catalog probability {catalog_probability} != DEM probability {dem_probability}" + ); + } + } + + for gate_type in [ + GateType::X, + GateType::Y, + GateType::Z, + GateType::H, + GateType::F, + GateType::Fdg, + GateType::SX, + GateType::SXdg, + GateType::SY, + GateType::SYdg, + GateType::SZ, + GateType::SZdg, + ] { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[QubitId(0)]); + add_1q_gate(&mut circuit, gate_type); + circuit.tick().mz(&[QubitId(0)]); + set_meta(&mut circuit, 1, r#"[{"id":0,"records":[-1]}]"#); + + let catalog = build_fault_catalog( + &circuit, + &StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + let locations: Vec<_> = catalog + .locations + .iter() + .filter(|location| location.gate_type == gate_type) + .collect(); + assert_eq!(locations.len(), 1, "{gate_type:?}"); + assert_eq!(locations[0].faults.len(), 3, "{gate_type:?}"); + + let dem = DemBuilder::from_tick_circuit(&circuit, 0.03, 0.0, 0.0, 0.0); + assert!( + dem_has_source(&dem, gate_type), + "DEM should track a source contribution for {gate_type:?}" + ); + assert_catalog_dem_probabilities_match(&catalog, &dem, gate_type); + } + + for gate_type in [ + GateType::CX, + GateType::CY, + GateType::CZ, + GateType::SXX, + GateType::SXXdg, + GateType::SYY, + GateType::SYYdg, + GateType::SZZ, + GateType::SZZdg, + GateType::SWAP, + ] { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[QubitId(0), QubitId(1)]); + add_2q_gate(&mut circuit, gate_type); + circuit.tick().mz(&[QubitId(0), QubitId(1)]); + set_meta( + &mut circuit, + 2, + r#"[{"id":0,"records":[-2]},{"id":1,"records":[-1]}]"#, + ); + + let catalog = build_fault_catalog( + &circuit, + &StochasticNoiseParams { + p1: 0.0, + p2: 0.15, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + let locations: Vec<_> = catalog + .locations + .iter() + .filter(|location| location.gate_type == gate_type) + .collect(); + assert_eq!(locations.len(), 1, "{gate_type:?}"); + assert_eq!(locations[0].faults.len(), 15, "{gate_type:?}"); + + let dem = DemBuilder::from_tick_circuit(&circuit, 0.0, 0.15, 0.0, 0.0); + assert!( + dem_has_source(&dem, gate_type), + "DEM should track a source contribution for {gate_type:?}" + ); + assert_catalog_dem_probabilities_match(&catalog, &dem, gate_type); + } + } + #[test] fn test_parse_detectors_json() { let json = r#"[ @@ -1002,6 +1929,20 @@ mod tests { assert_eq!(observables[0].records, vec![-1, -3, -5]); } + #[test] + fn test_dem_builder_accepts_observables_json_alias() { + let influence_map = DagFaultInfluenceMap::with_capacity(0); + let dem = DemBuilder::new(&influence_map) + .with_observables_json(r#"[{"id": 0, "records": [-1, -3]}]"#) + .unwrap() + .build(); + + assert_eq!(dem.num_dem_outputs(), 1); + assert_eq!(dem.num_observables(), 1); + assert_eq!(dem.num_tracked_paulis(), 0); + assert_eq!(dem.dem_outputs()[0].records.as_slice(), &[-1, -3]); + } + #[test] fn test_parse_empty_json() { assert!(parse_detectors_json("").unwrap().is_empty()); 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 2aa147dcf..7ba0febe2 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 @@ -10,10 +10,18 @@ // or implied. See the License for the specific language governing permissions and limitations under // the License. +//! Internal sampling engine for threshold estimation. +//! +//! This is the core CSR/geometric-skip engine used by [`super::DemSampler`]. +//! External consumers should use `DemSampler` and `DemSamplerBuilder` from +//! the parent module. +#![allow(dead_code)] // Many methods are public for internal use but not called externally yet + //! Fast DEM-style sampler for threshold estimation. //! //! This module provides a sampler that aggregates fault effects directly into -//! detector/observable signatures, matching Stim's DEM sampler semantics. +//! detector/standard observable `L` signatures, matching DEM sampling +//! semantics. //! //! # Data-Oriented Design //! @@ -21,7 +29,7 @@ //! cache-efficient sampling: //! //! - **Probabilities**: Stored in a contiguous array for sequential access -//! - **Detector/Observable indices**: CSR layout (offsets + flat data) for variable-length lists +//! - **Detector/observable indices**: CSR layout (offsets + flat data) for variable-length lists //! - **Bit-packed outcomes**: Uses `u64` words for compact detector/observable state //! //! # Example @@ -30,8 +38,7 @@ //! use pecos_qec::fault_tolerance::DagFaultAnalyzer; //! use pecos_qec::fault_tolerance::dem_builder::DemSamplerBuilder; //! use pecos_quantum::DagCircuit; -//! use rand::SeedableRng; -//! use rand::rngs::SmallRng; +//! use pecos_random::PecosRng; //! //! let mut dag = DagCircuit::new(); //! dag.pz(&[2]); @@ -41,19 +48,18 @@ //! //! let analyzer = DagFaultAnalyzer::new(&dag); //! let influence_map = analyzer.build_influence_map(); -//! let detectors_json = r#"[{"id": 0, "records": [-1]}]"#; -//! let observables_json = "[]"; //! -//! // Build from circuit with detector definitions +//! // Build sampler with detector definitions //! let sampler = DemSamplerBuilder::new(&influence_map) //! .with_noise(0.01, 0.01, 0.01, 0.01) -//! .with_detectors_json(detectors_json).unwrap() -//! .with_observables_json(observables_json).unwrap() -//! .build(); +//! .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#).unwrap() +//! .with_observables_json("[]").unwrap() +//! .build() +//! .unwrap(); //! //! // Fast batch sampling for threshold estimation -//! let mut rng = SmallRng::seed_from_u64(42); -//! let (det_events, obs_flips) = sampler.sample_batch(100, &mut rng); +//! let mut rng = PecosRng::seed_from_u64(42); +//! let (det_events, dem_output_flips) = sampler.sample_batch(100, &mut rng); //! ``` use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; @@ -62,44 +68,44 @@ use pecos_random::{PecosRng, RngProbabilityExt}; use rand_core::Rng; use rayon::prelude::*; use smallvec::SmallVec; -use std::collections::BTreeMap; +use std::collections::{BTreeMap, BTreeSet}; use wide::u64x4; -use super::types::combine_probabilities; +use super::types::{NoiseConfig, PerGateTypeNoise, combine_probabilities}; // ============================================================================ // DEM Mechanism (used during building) // ============================================================================ -/// A single fault mechanism with its detector/observable effects. +/// A single fault mechanism with its detector and standard observable `L` effects. /// Used during building, then converted to `SoA` layout. #[derive(Debug, Clone, PartialEq, Eq, PartialOrd, Ord, Hash)] struct DemMechanism { /// Sorted detector indices that flip when this mechanism fires. detectors: SmallVec<[u32; 4]>, - /// Sorted observable indices that flip when this mechanism fires. - observables: SmallVec<[u32; 2]>, + /// Sorted standard observable `L` indices that flip when this mechanism fires. + dem_outputs: SmallVec<[u32; 2]>, } impl DemMechanism { - fn new(mut detectors: SmallVec<[u32; 4]>, mut observables: SmallVec<[u32; 2]>) -> Self { + fn new(mut detectors: SmallVec<[u32; 4]>, mut dem_outputs: SmallVec<[u32; 2]>) -> Self { detectors.sort_unstable(); - observables.sort_unstable(); + dem_outputs.sort_unstable(); Self { detectors, - observables, + dem_outputs, } } fn empty() -> Self { Self { detectors: SmallVec::new(), - observables: SmallVec::new(), + dem_outputs: SmallVec::new(), } } fn is_empty(&self) -> bool { - self.detectors.is_empty() && self.observables.is_empty() + self.detectors.is_empty() && self.dem_outputs.is_empty() } } @@ -169,13 +175,13 @@ impl PackedBits { /// Fast DEM-style sampler for threshold estimation. /// /// Uses Structure of Arrays (`SoA`) layout with CSR-style indexing for -/// cache-efficient sampling. Detector and observable outcomes are bit-packed +/// cache-efficient sampling. Detector and `L` target outcomes are bit-packed /// for compact storage and fast XOR operations. /// /// # Data-Oriented Design /// /// - **Precomputed thresholds**: Probabilities converted to u64 thresholds at build time -/// - **CSR layout**: Detector/observable indices in flat arrays with offsets +/// - **CSR layout**: Detector/`L` target indices in flat arrays with offsets /// - **Bit-packed outcomes**: Uses u64 words for compact XOR operations /// - **Batch RNG**: Can use bulk random number generation for cache efficiency /// @@ -185,15 +191,15 @@ impl PackedBits { /// thresholds: [t0, t1, t2, ...] (precomputed u64, sequential read) /// detector_offsets: [0, 2, 3, 5, ...] (CSR row pointers) /// detector_data: [d0, d1, d2, d3, d4, ...] (flat detector indices) -/// observable_offsets: [0, 0, 1, 1, ...] (CSR row pointers) -/// observable_data: [o0, ...] (flat observable indices) +/// dem_output_offsets: [0, 0, 1, 1, ...] (CSR row pointers) +/// dem_output_data: [t0, ...] (flat `L` target indices) /// ``` /// /// For mechanism i: /// - Detector indices: `detector_data[detector_offsets[i]..detector_offsets[i+1]]` -/// - Observable indices: `observable_data[observable_offsets[i]..observable_offsets[i+1]]` +/// - `L` target indices: `dem_output_data[dem_output_offsets[i]..dem_output_offsets[i+1]]` #[derive(Debug, Clone)] -pub struct DemSampler { +pub struct SamplingEngine { // SoA layout for cache efficiency /// Precomputed u64 thresholds (faster than f64 comparison). thresholds: Vec, @@ -207,18 +213,29 @@ pub struct DemSampler { /// Flat array of detector indices. detector_data: Vec, - /// CSR-style offsets into `observable_data`. Length = `num_mechanisms` + 1. - observable_offsets: Vec, - /// Flat array of observable indices. - observable_data: Vec, + /// CSR-style offsets into `dem_output_data`. Length = `num_mechanisms` + 1. + /// These are standard observable `L` DEM outputs. + dem_output_offsets: Vec, + /// Flat array of `L` target indices. + dem_output_data: Vec, /// Number of detectors. num_detectors: usize, - /// Number of observables. - num_observables: usize, + /// Number of DEM `L` outputs. + num_dem_outputs: usize, +} + +const U32_BASE_AS_F64: f64 = 4_294_967_296.0; +const U64_MAX_AS_F64: f64 = 18_446_744_073_709_551_615.0; + +fn threshold_to_probability(threshold: u64) -> f64 { + let hi = u32::try_from(threshold >> 32).expect("upper threshold word fits in u32"); + let lo = + u32::try_from(threshold & u64::from(u32::MAX)).expect("lower threshold word fits in u32"); + (f64::from(hi) * U32_BASE_AS_F64 + f64::from(lo)) / U64_MAX_AS_F64 } -impl DemSampler { +impl SamplingEngine { /// Number of mechanisms in the sampler. #[must_use] pub fn num_mechanisms(&self) -> usize { @@ -231,25 +248,64 @@ impl DemSampler { self.num_detectors } - /// Number of observables. + /// Number of observables represented by `L` columns. + /// + /// When no PECOS tracked-Pauli metadata is present, every `L` output + /// is treated as an observable. #[must_use] pub fn num_observables(&self) -> usize { - self.num_observables + self.num_dem_outputs } - /// Create a [`DemSampler`] from raw mechanism data. + /// Number of DEM `L` outputs. + #[must_use] + pub fn num_dem_outputs(&self) -> usize { + self.num_dem_outputs + } + + /// Reconstruct a [`DetectorErrorModel`] from the aggregated `SoA` + /// mechanism state for text output (e.g. Stim-format via + /// [`DetectorErrorModel::to_string`]). + /// + /// Each stored mechanism becomes a `direct` contribution with its + /// approximate probability (recovered from the `u64` threshold). + /// Detector / observable declarations are NOT emitted -- those + /// require the original `DetectorDef` / observable metadata + /// definitions held by higher-level wrappers such as + /// [`crate::dem_stab::DemStabSim`]. Callers who need full metadata + /// should populate those on the returned DEM. + #[must_use] + pub fn to_detector_error_model(&self) -> super::types::DetectorErrorModel { + use super::types::{DetectorErrorModel, FaultMechanism}; + let mut dem = DetectorErrorModel::with_capacity(self.num_detectors, self.num_dem_outputs); + for i in 0..self.thresholds.len() { + let prob = threshold_to_probability(self.thresholds[i]); + let det_start = self.detector_offsets[i] as usize; + let det_end = self.detector_offsets[i + 1] as usize; + let obs_start = self.dem_output_offsets[i] as usize; + let obs_end = self.dem_output_offsets[i + 1] as usize; + let mechanism = FaultMechanism::from_unsorted( + self.detector_data[det_start..det_end].iter().copied(), + self.dem_output_data[obs_start..obs_end].iter().copied(), + ); + dem.add_direct_contribution(mechanism, prob); + } + dem + } + + /// Create a [`SamplingEngine`] from raw mechanism data. /// /// This constructor is used when building from a parsed DEM string rather than /// from a circuit analysis. Each mechanism is specified by its probability and - /// the detector/observable indices it affects. + /// the detector/`L` target indices it affects. /// /// # Arguments /// - /// * `mechanisms` - Iterator of (probability, `detector_indices`, `observable_indices`) + /// * `mechanisms` - Iterator of (probability, `detector_indices`, `dem_output_indices`) /// * `num_detectors` - Total number of detectors - /// * `num_observables` - Total number of observables + /// * `num_dem_outputs` - Total number of standard observable `L` outputs #[must_use] - pub fn from_mechanisms(mechanisms: I, num_detectors: usize, num_observables: usize) -> Self + pub fn from_mechanisms(mechanisms: I, num_detectors: usize, num_dem_outputs: usize) -> Self where I: IntoIterator, Vec)>, { @@ -260,16 +316,16 @@ impl DemSampler { let mut inv_log_1_minus_p = Vec::with_capacity(num_mechanisms); let mut detector_offsets = Vec::with_capacity(num_mechanisms + 1); let mut detector_data = Vec::new(); - let mut observable_offsets = Vec::with_capacity(num_mechanisms + 1); - let mut observable_data = Vec::new(); + let mut dem_output_offsets = Vec::with_capacity(num_mechanisms + 1); + let mut dem_output_data = Vec::new(); detector_offsets.push(0); - observable_offsets.push(0); + dem_output_offsets.push(0); - for (prob, mut detectors, mut observables) in mechanisms { + for (prob, mut detectors, mut dem_outputs) in mechanisms { // Sort for canonical representation detectors.sort_unstable(); - observables.sort_unstable(); + dem_outputs.sort_unstable(); // Precompute u64 threshold: p * u64::MAX #[allow( @@ -293,9 +349,9 @@ impl DemSampler { #[allow(clippy::cast_possible_truncation)] // detector data length fits in u32 detector_offsets.push(detector_data.len() as u32); - observable_data.extend_from_slice(&observables); - #[allow(clippy::cast_possible_truncation)] // observable data length fits in u32 - observable_offsets.push(observable_data.len() as u32); + dem_output_data.extend_from_slice(&dem_outputs); + #[allow(clippy::cast_possible_truncation)] // `L` target data length fits in u32 + dem_output_offsets.push(dem_output_data.len() as u32); } Self { @@ -303,20 +359,153 @@ impl DemSampler { inv_log_1_minus_p, detector_offsets, detector_data, - observable_offsets, - observable_data, + dem_output_offsets, + dem_output_data, num_detectors, - num_observables, + num_dem_outputs, + } + } + + /// Create a [`SamplingEngine`] directly from a [`DagFaultInfluenceMap`] and + /// per-location error probabilities. + /// + /// Each fault location is treated as depolarizing: probability `p` at + /// location `i` means X, Y, and Z faults each occur with probability + /// `p/3`. Mechanisms with identical detector/`L` target effects are + /// aggregated automatically. + /// + /// This constructor works with the influence map's raw measurement and + /// DEM-output indices — no explicit detector or observable metadata + /// definitions are needed. + /// + /// # Arguments + /// + /// * `influence_map` - Precomputed fault influence map. + /// * `per_location_probs` - Error probability for each per-qubit fault location + /// (length must equal `influence_map.locations.len()`). + /// + /// Uses the per-gate noise model: each gate faults with probability p, + /// and each non-identity Pauli is equally likely (p/3 for 1-qubit, + /// p/15 for 2-qubit). For idle gates with T1/T2 noise, the Pauli + /// distribution is biased (more Z than X/Y). + #[must_use] + pub fn from_influence_map( + influence_map: &DagFaultInfluenceMap, + per_location_probs: &[f64], + noise: &super::NoiseConfig, + ) -> Self { + use pecos_core::gate_type::GateType; + + let mut aggregated: BTreeMap = BTreeMap::new(); + + let gate_locs = influence_map.gate_fault_locations(); + + for loc in &gate_locs { + let p = super::sampler::gate_location_prob_from_locations( + loc, + per_location_probs, + &influence_map.locations, + ); + if p <= 0.0 { + continue; + } + + let events = loc.all_events(); + if events.is_empty() { + continue; + } + + // For idle gates with T1/T2 noise, use per-Pauli probabilities. + // For all other gates, divide equally among events. + let is_idle = loc.gate_type == GateType::Idle; + let idle_pauli_probs = if is_idle { + let duration = influence_map + .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)) + } else { + None + }; + + // Get per-event probabilities based on gate type and noise config + let n_qubits = loc.num_qubits(); + let custom_weights = if idle_pauli_probs.is_some() { + None + } else if n_qubits == 1 { + noise.p1_weights.as_ref() + } else { + noise.p2_weights.as_ref() + }; + + let event_weights: Vec = if let Some(pp) = &idle_pauli_probs { + // T1/T2 idle: absolute per-Pauli probabilities + events + .iter() + .map(|event| { + let pauli = event + .pauli + .paulis() + .first() + .map_or(pecos_core::Pauli::I, |&(pa, _)| pa); + match pauli { + pecos_core::Pauli::X => pp.px, + pecos_core::Pauli::Y => pp.py, + pecos_core::Pauli::Z => pp.pz, + pecos_core::Pauli::I => 0.0, + } + }) + .collect() + } else if let Some(weights) = custom_weights { + // Custom per-Pauli weights: p * weight_for(pauli) + events + .iter() + .map(|event| p * weights.weight_for(&event.pauli)) + .collect() + } else { + // Default uniform: p / num_events + // Event count is a small integer; precision loss is not a concern. + #[allow(clippy::cast_precision_loss)] + let per_event = p / events.len() as f64; + vec![per_event; events.len()] + }; + + for (event, &event_prob) in events.iter().zip(&event_weights) { + let det_indices: SmallVec<[u32; 4]> = event.detectors.iter().copied().collect(); + let dem_output_indices: SmallVec<[u32; 2]> = event + .dem_outputs + .iter() + .filter_map(|&idx| influence_map.observable_id_for_internal_dem_output(idx)) + .collect(); + + let mech = DemMechanism::new(det_indices, dem_output_indices); + if !mech.is_empty() { + let entry = aggregated.entry(mech).or_insert(0.0); + *entry = combine_probabilities(*entry, event_prob); + } + } } + + let num_detectors = influence_map.detectors.len(); + let num_dem_outputs = influence_map.num_dem_outputs(); + + let mechanisms = aggregated + .into_iter() + .map(|(mech, prob)| (prob, mech.detectors.to_vec(), mech.dem_outputs.to_vec())); + + Self::from_mechanisms(mechanisms, num_detectors, num_dem_outputs) } /// Sample a single shot. /// - /// Returns (`detection_events`, `observable_flips`) as boolean vectors. + /// Returns (`detection_events`, `dem_output_flips`) as boolean vectors. #[must_use] pub fn sample(&self, rng: &mut R) -> (Vec, Vec) { let mut det_bits = PackedBits::new(self.num_detectors); - let mut obs_bits = PackedBits::new(self.num_observables); + let mut obs_bits = PackedBits::new(self.num_dem_outputs); self.sample_into_packed(&mut det_bits, &mut obs_bits, rng); @@ -341,16 +530,16 @@ impl DemSampler { for i in 0..num_mechanisms { // Fast integer comparison with precomputed threshold if rng.check_probability(self.thresholds[i]) { - // Mechanism fired - XOR in detector/observable effects + // Mechanism fired - XOR in detector/`L` target effects let det_start = self.detector_offsets[i] as usize; let det_end = self.detector_offsets[i + 1] as usize; for &d in &self.detector_data[det_start..det_end] { det_bits.flip(d as usize); } - let obs_start = self.observable_offsets[i] as usize; - let obs_end = self.observable_offsets[i + 1] as usize; - for &o in &self.observable_data[obs_start..obs_end] { + let obs_start = self.dem_output_offsets[i] as usize; + let obs_end = self.dem_output_offsets[i + 1] as usize; + for &o in &self.dem_output_data[obs_start..obs_end] { obs_bits.flip(o as usize); } } @@ -359,24 +548,66 @@ impl DemSampler { /// Sample multiple shots. /// - /// Returns (`all_detection_events`, `all_observable_flips`). + /// Uses geometric skip sampling internally — O(fired mechanisms) per + /// shot instead of O(all mechanisms). Automatically converts from + /// columnar bit-packed format to row-major `Vec`. + /// + /// Returns (`all_detection_events`, `all_dem_output_flips`). #[must_use] pub fn sample_batch( &self, num_shots: usize, rng: &mut R, ) -> (Vec>, Vec>) { - let mut all_det_events = Vec::with_capacity(num_shots); - let mut all_obs_flips = Vec::with_capacity(num_shots); + if num_shots == 0 { + return (vec![], vec![]); + } - // Pre-allocate work arrays - let mut det_bits = PackedBits::new(self.num_detectors); - let mut obs_bits = PackedBits::new(self.num_observables); + // Sample using geometric skip (fast at low error rates). + let (det_columns, obs_columns) = self.sample_batch_columnar_geometric(num_shots, rng); - for _ in 0..num_shots { - self.sample_into_packed(&mut det_bits, &mut obs_bits, rng); - all_det_events.push(det_bits.to_vec()); - all_obs_flips.push(obs_bits.to_vec()); + // Convert columnar bit-packed → row-major Vec. + let mut all_det_events: Vec> = (0..num_shots) + .map(|_| vec![false; self.num_detectors]) + .collect(); + let mut all_obs_flips: Vec> = (0..num_shots) + .map(|_| vec![false; self.num_dem_outputs]) + .collect(); + + for (det_idx, col) in det_columns.iter().enumerate() { + for (word_idx, &word) in col.iter().enumerate() { + if word == 0 { + continue; + } + let base_shot = word_idx * BITS_PER_WORD; + let mut w = word; + while w != 0 { + let bit = w.trailing_zeros() as usize; + let shot = base_shot + bit; + if shot < num_shots { + all_det_events[shot][det_idx] = true; + } + w &= w - 1; + } + } + } + + for (obs_idx, col) in obs_columns.iter().enumerate() { + for (word_idx, &word) in col.iter().enumerate() { + if word == 0 { + continue; + } + let base_shot = word_idx * BITS_PER_WORD; + let mut w = word; + while w != 0 { + let bit = w.trailing_zeros() as usize; + let shot = base_shot + bit; + if shot < num_shots { + all_obs_flips[shot][obs_idx] = true; + } + w &= w - 1; + } + } } (all_det_events, all_obs_flips) @@ -411,6 +642,31 @@ impl DemSampler { self.sample_statistics_auto_internal(&mut rng, num_shots) } + /// Compute statistics where only the specified DEM outputs count as observables. + /// + /// The sampler still reports per-DEM-output flip counts for every `L` + /// output. `logical_error_count` and `undetectable_count` are computed + /// from the selected observable outputs only, so unmeasured tracked + /// Paulis do not affect decoder-style observable statistics. + #[must_use] + pub fn sample_statistics_for_observable_indices( + &self, + num_shots: usize, + seed: u64, + observable_indices: &[usize], + ) -> SamplingStatistics { + if self.all_dem_outputs_selected(observable_indices) { + return self.sample_statistics(num_shots, seed); + } + + let mut rng = PecosRng::seed_from_u64(seed); + self.sample_statistics_with_rng_for_observable_indices( + num_shots, + &mut rng, + observable_indices, + ) + } + /// Compute statistics with a user-provided RNG. /// /// Use this when you need control over the random number generator, @@ -429,6 +685,43 @@ impl DemSampler { self.sample_statistics_auto_internal(rng, num_shots) } + /// Compute statistics with a user-provided RNG and explicit observable DEM-output indices. + #[must_use] + pub fn sample_statistics_with_rng_for_observable_indices( + &self, + num_shots: usize, + rng: &mut R, + observable_indices: &[usize], + ) -> SamplingStatistics { + if self.all_dem_outputs_selected(observable_indices) { + return self.sample_statistics_with_rng(num_shots, rng); + } + if num_shots == 0 || self.thresholds.is_empty() { + return SamplingStatistics::with_channels( + num_shots, + self.num_detectors, + self.num_dem_outputs, + ); + } + + let (det_columns, dem_output_columns) = + self.sample_batch_columnar_geometric(num_shots, rng); + Self::compute_statistics_from_columns_for_observables( + &det_columns, + &dem_output_columns, + num_shots, + observable_indices, + ) + } + + fn all_dem_outputs_selected(&self, observable_indices: &[usize]) -> bool { + observable_indices.len() == self.num_dem_outputs + && observable_indices + .iter() + .copied() + .eq(0..self.num_dem_outputs) + } + /// Internal: sample statistics using the most efficient method. /// /// Uses chunked processing for large working sets (>6 MB) to improve cache @@ -445,7 +738,7 @@ impl DemSampler { /// Optimized statistics sampling using flat array layout. /// /// This method provides faster sampling than nested Vec> by: - /// - Using a flat contiguous array for detector/observable columns + /// - Using a flat contiguous array for detector/`L` target columns /// - Better cache locality due to predictable memory access patterns /// /// This method is semantically equivalent to the columnar methods. @@ -461,9 +754,9 @@ impl DemSampler { // XOR semantics required for correct detector behavior let mut det_data: Vec = vec![0u64; self.num_detectors * num_words]; - // Flat array for observable columns (XOR semantics) + // Flat array for `L` target columns (XOR semantics) // Layout: obs_data[obs_idx * num_words + word_idx] - let mut obs_data: Vec = vec![0u64; self.num_observables * num_words]; + let mut obs_data: Vec = vec![0u64; self.num_dem_outputs * num_words]; for mech_idx in 0..num_mechanisms { let threshold = self.thresholds[mech_idx]; @@ -473,8 +766,8 @@ impl DemSampler { let det_start = self.detector_offsets[mech_idx] as usize; let det_end = self.detector_offsets[mech_idx + 1] as usize; - let obs_start = self.observable_offsets[mech_idx] as usize; - let obs_end = self.observable_offsets[mech_idx + 1] as usize; + let obs_start = self.dem_output_offsets[mech_idx] as usize; + let obs_end = self.dem_output_offsets[mech_idx + 1] as usize; // Skip if mechanism affects nothing if det_start == det_end && obs_start == obs_end { @@ -507,7 +800,7 @@ impl DemSampler { } // XOR each affected observable - for &o in &self.observable_data[obs_start..obs_end] { + for &o in &self.dem_output_data[obs_start..obs_end] { let idx = o as usize * num_words + word_idx; obs_data[idx] ^= mask; } @@ -525,20 +818,62 @@ impl DemSampler { } } - // Compute logical error mask by ORing all observable columns - let mut logical_words = vec![0u64; num_words]; - for obs_idx in 0..self.num_observables { + // Compute logical-error mask by ORing all selected standard observable columns. + let mut observable_words = vec![0u64; num_words]; + for obs_idx in 0..self.num_dem_outputs { let base = obs_idx * num_words; for word_idx in 0..num_words { - logical_words[word_idx] |= obs_data[base + word_idx]; + observable_words[word_idx] |= obs_data[base + word_idx]; } } // Count statistics - let mut stats = SamplingStatistics::new(num_shots); + let mut stats = + SamplingStatistics::with_channels(num_shots, self.num_detectors, self.num_dem_outputs); + + // Per-channel counts (cheap -- data is already in cache from the OR passes above) + for det_idx in 0..self.num_detectors { + let base = det_idx * num_words; + let mut count = 0usize; + for word_idx in 0..num_words { + let valid_bits = if word_idx == num_words - 1 { + let remaining = num_shots % BITS_PER_WORD; + if remaining == 0 { + !0u64 + } else { + (1u64 << remaining) - 1 + } + } else { + !0u64 + }; + count += (det_data[base + word_idx] & valid_bits).count_ones() as usize; + } + stats.per_detector[det_idx] = count; + } + + for obs_idx in 0..self.num_dem_outputs { + let base = obs_idx * num_words; + let mut count = 0usize; + for word_idx in 0..num_words { + let valid_bits = if word_idx == num_words - 1 { + let remaining = num_shots % BITS_PER_WORD; + if remaining == 0 { + !0u64 + } else { + (1u64 << remaining) - 1 + } + } else { + !0u64 + }; + count += (obs_data[base + word_idx] & valid_bits).count_ones() as usize; + } + stats.per_dem_output[obs_idx] = count; + } + + // Aggregate counts for word_idx in 0..num_words { let syndrome = syndrome_words[word_idx]; - let logical = logical_words[word_idx]; + let observable = observable_words[word_idx]; let valid_bits = if word_idx == num_words - 1 { let remaining = num_shots % BITS_PER_WORD; @@ -552,11 +887,12 @@ impl DemSampler { }; let syndrome_masked = syndrome & valid_bits; - let logical_masked = logical & valid_bits; + let observable_masked = observable & valid_bits; stats.syndrome_count += syndrome_masked.count_ones() as usize; - stats.logical_error_count += logical_masked.count_ones() as usize; - stats.undetectable_count += (logical_masked & !syndrome_masked).count_ones() as usize; + stats.logical_error_count += observable_masked.count_ones() as usize; + stats.undetectable_count += + (observable_masked & !syndrome_masked).count_ones() as usize; } stats @@ -568,9 +904,9 @@ impl DemSampler { /// within the target cache size (L3). Returns `None` if the buffer is already /// small enough that chunking wouldn't help. fn optimal_chunk_size(&self, num_shots: usize) -> Option { - // Calculate full buffer size: (num_detectors + num_observables) * num_words * 8 bytes + // Calculate full buffer size: (num_detectors + num_dem_outputs) * num_words * 8 bytes let num_words = num_shots.div_ceil(BITS_PER_WORD); - let full_buffer_bytes = (self.num_detectors + self.num_observables) * num_words * 8; + let full_buffer_bytes = (self.num_detectors + self.num_dem_outputs) * num_words * 8; // Only chunk if buffer exceeds target cache size if full_buffer_bytes <= TARGET_CHUNK_BUFFER_BYTES { @@ -578,9 +914,9 @@ impl DemSampler { } // Calculate chunk size that fits in cache - // Buffer = (num_detectors + num_observables) * (chunk_shots / 64) * 8 - // chunk_shots = TARGET * 64 / ((num_detectors + num_observables) * 8) - let total_columns = self.num_detectors + self.num_observables; + // Buffer = (num_detectors + num_dem_outputs) * (chunk_shots / 64) * 8 + // chunk_shots = TARGET * 64 / ((num_detectors + num_dem_outputs) * 8) + let total_columns = self.num_detectors + self.num_dem_outputs; if total_columns == 0 { return None; } @@ -616,7 +952,8 @@ impl DemSampler { return self.sample_statistics_direct(num_shots, rng); }; - let mut total_stats = SamplingStatistics::new(num_shots); + let mut total_stats = + SamplingStatistics::with_channels(num_shots, self.num_detectors, self.num_dem_outputs); let mut shot_offset = 0; while shot_offset < num_shots { @@ -626,6 +963,20 @@ impl DemSampler { total_stats.syndrome_count += chunk_stats.syndrome_count; total_stats.logical_error_count += chunk_stats.logical_error_count; total_stats.undetectable_count += chunk_stats.undetectable_count; + for (total, chunk) in total_stats + .per_detector + .iter_mut() + .zip(&chunk_stats.per_detector) + { + *total += chunk; + } + for (total, chunk) in total_stats + .per_dem_output + .iter_mut() + .zip(&chunk_stats.per_dem_output) + { + *total += chunk; + } shot_offset += chunk_shots; } @@ -641,26 +992,39 @@ impl DemSampler { num_shots: usize, rng: &mut R, ) -> SamplingStatistics { - let mut stats = SamplingStatistics::new(num_shots); + let mut stats = + SamplingStatistics::with_channels(num_shots, self.num_detectors, self.num_dem_outputs); let mut det_bits = PackedBits::new(self.num_detectors); - let mut obs_bits = PackedBits::new(self.num_observables); + let mut obs_bits = PackedBits::new(self.num_dem_outputs); for _ in 0..num_shots { self.sample_into_packed(&mut det_bits, &mut obs_bits, rng); let has_syndrome = det_bits.any(); - let has_logical_error = obs_bits.any(); + let has_observable_error = obs_bits.any(); - if has_logical_error { + if has_observable_error { stats.logical_error_count += 1; } if has_syndrome { stats.syndrome_count += 1; } - if has_logical_error && !has_syndrome { + if has_observable_error && !has_syndrome { stats.undetectable_count += 1; } + + // Per-channel counts + for (i, count) in stats.per_detector.iter_mut().enumerate() { + if det_bits.get(i) { + *count += 1; + } + } + for (i, count) in stats.per_dem_output.iter_mut().enumerate() { + if obs_bits.get(i) { + *count += 1; + } + } } stats @@ -675,7 +1039,7 @@ impl DemSampler { /// This method processes all shots for each mechanism at once, enabling: /// - Bulk random number generation (64 shots per u64) /// - Better cache locality for threshold comparisons - /// - Vectorized XOR operations on detector/observable columns + /// - Vectorized XOR operations on detector/`L` target columns /// /// Returns columnar bit-packed results: (detector_columns, observable_columns) /// where each column is a Vec with bit i of word w = shot w*64 + i. @@ -689,17 +1053,17 @@ impl DemSampler { if num_shots == 0 { return ( vec![vec![]; self.num_detectors], - vec![vec![]; self.num_observables], + vec![vec![]; self.num_dem_outputs], ); } let num_words = num_shots.div_ceil(BITS_PER_WORD); - // Initialize detector and observable columns (all zeros) + // Initialize detector and `L` target columns (all zeros) let mut det_columns: Vec> = (0..self.num_detectors) .map(|_| vec![0u64; num_words]) .collect(); - let mut obs_columns: Vec> = (0..self.num_observables) + let mut obs_columns: Vec> = (0..self.num_dem_outputs) .map(|_| vec![0u64; num_words]) .collect(); @@ -716,9 +1080,7 @@ impl DemSampler { } // Generate bulk random numbers for this mechanism - for word in &mut random_words { - *word = rng.next_u64(); - } + rng.fill_u64(&mut random_words); // For each word, check threshold and apply effects for word_idx in 0..num_words { @@ -737,10 +1099,10 @@ impl DemSampler { det_columns[d as usize][word_idx] ^= !0u64; } - // XOR effects into observable columns - let obs_start = self.observable_offsets[mech_idx] as usize; - let obs_end = self.observable_offsets[mech_idx + 1] as usize; - for &o in &self.observable_data[obs_start..obs_end] { + // XOR effects into `L` target columns + let obs_start = self.dem_output_offsets[mech_idx] as usize; + let obs_end = self.dem_output_offsets[mech_idx + 1] as usize; + for &o in &self.dem_output_data[obs_start..obs_end] { obs_columns[o as usize][word_idx] ^= !0u64; } } @@ -763,17 +1125,17 @@ impl DemSampler { if num_shots == 0 { return ( vec![vec![]; self.num_detectors], - vec![vec![]; self.num_observables], + vec![vec![]; self.num_dem_outputs], ); } let num_words = num_shots.div_ceil(BITS_PER_WORD); - // Initialize detector and observable columns (all zeros) + // Initialize detector and `L` target columns (all zeros) let mut det_columns: Vec> = (0..self.num_detectors) .map(|_| vec![0u64; num_words]) .collect(); - let mut obs_columns: Vec> = (0..self.num_observables) + let mut obs_columns: Vec> = (0..self.num_dem_outputs) .map(|_| vec![0u64; num_words]) .collect(); @@ -786,11 +1148,11 @@ impl DemSampler { continue; } - // Get detector/observable indices for this mechanism + // Get detector/`L` target indices for this mechanism let det_start = self.detector_offsets[mech_idx] as usize; let det_end = self.detector_offsets[mech_idx + 1] as usize; - let obs_start = self.observable_offsets[mech_idx] as usize; - let obs_end = self.observable_offsets[mech_idx + 1] as usize; + let obs_start = self.dem_output_offsets[mech_idx] as usize; + let obs_end = self.dem_output_offsets[mech_idx + 1] as usize; // For each word (64 shots), generate random bits and check threshold for word_idx in 0..num_words { @@ -824,8 +1186,8 @@ impl DemSampler { det_columns[d as usize][word_idx] ^= fired_mask; } - // XOR the fired mask into affected observable columns - for &o in &self.observable_data[obs_start..obs_end] { + // XOR the fired mask into affected `L` target columns + for &o in &self.dem_output_data[obs_start..obs_end] { obs_columns[o as usize][word_idx] ^= fired_mask; } } @@ -858,18 +1220,18 @@ impl DemSampler { } } - // OR all observable columns to get logical error mask - let mut logical_words = vec![0u64; num_words]; + // OR all standard observable columns to get a logical-error mask. + let mut observable_words = vec![0u64; num_words]; for col in &obs_columns { for (i, &word) in col.iter().enumerate() { - logical_words[i] |= word; + observable_words[i] |= word; } } - // Count shots with syndrome, logical error, undetectable + // Count shots with syndrome, logical error, undetectable error for word_idx in 0..num_words { let syndrome = syndrome_words[word_idx]; - let logical = logical_words[word_idx]; + let observable = observable_words[word_idx]; // Mask out unused bits in the last word let valid_bits = if word_idx == num_words - 1 { @@ -884,11 +1246,12 @@ impl DemSampler { }; let syndrome_masked = syndrome & valid_bits; - let logical_masked = logical & valid_bits; + let observable_masked = observable & valid_bits; stats.syndrome_count += syndrome_masked.count_ones() as usize; - stats.logical_error_count += logical_masked.count_ones() as usize; - stats.undetectable_count += (logical_masked & !syndrome_masked).count_ones() as usize; + stats.logical_error_count += observable_masked.count_ones() as usize; + stats.undetectable_count += + (observable_masked & !syndrome_masked).count_ones() as usize; } stats @@ -911,18 +1274,18 @@ impl DemSampler { if num_shots == 0 { return ( vec![vec![]; self.num_detectors], - vec![vec![]; self.num_observables], + vec![vec![]; self.num_dem_outputs], ); } let num_words = num_shots.div_ceil(BITS_PER_WORD); let num_simd_words = num_words.div_ceil(4); - // Initialize detector and observable columns as SIMD vectors + // Initialize detector and `L` target columns as SIMD vectors let mut det_columns: Vec> = (0..self.num_detectors) .map(|_| vec![u64x4::ZERO; num_simd_words]) .collect(); - let mut obs_columns: Vec> = (0..self.num_observables) + let mut obs_columns: Vec> = (0..self.num_dem_outputs) .map(|_| vec![u64x4::ZERO; num_simd_words]) .collect(); @@ -937,11 +1300,11 @@ impl DemSampler { continue; } - // Get detector/observable indices for this mechanism + // Get detector/`L` target indices for this mechanism let det_start = self.detector_offsets[mech_idx] as usize; let det_end = self.detector_offsets[mech_idx + 1] as usize; - let obs_start = self.observable_offsets[mech_idx] as usize; - let obs_end = self.observable_offsets[mech_idx + 1] as usize; + let obs_start = self.dem_output_offsets[mech_idx] as usize; + let obs_end = self.dem_output_offsets[mech_idx + 1] as usize; // Generate all random numbers for this mechanism at once // Use fill_u64 from RngProbabilityExt for potentially optimized bulk generation @@ -995,8 +1358,8 @@ impl DemSampler { det_columns[d as usize][simd_idx] ^= fired_vec; } - // XOR into affected observable columns - for &o in &self.observable_data[obs_start..obs_end] { + // XOR into affected `L` target columns + for &o in &self.dem_output_data[obs_start..obs_end] { obs_columns[o as usize][simd_idx] ^= fired_vec; } } @@ -1060,17 +1423,17 @@ impl DemSampler { if num_shots == 0 { return ( vec![vec![]; self.num_detectors], - vec![vec![]; self.num_observables], + vec![vec![]; self.num_dem_outputs], ); } let num_words = num_shots.div_ceil(BITS_PER_WORD); - // Initialize detector and observable columns + // Initialize detector and `L` target columns let mut det_columns: Vec> = (0..self.num_detectors) .map(|_| vec![0u64; num_words]) .collect(); - let mut obs_columns: Vec> = (0..self.num_observables) + let mut obs_columns: Vec> = (0..self.num_dem_outputs) .map(|_| vec![0u64; num_words]) .collect(); @@ -1082,11 +1445,11 @@ impl DemSampler { continue; } - // Get detector/observable indices + // Get detector/`L` target indices let det_start = self.detector_offsets[mech_idx] as usize; let det_end = self.detector_offsets[mech_idx + 1] as usize; - let obs_start = self.observable_offsets[mech_idx] as usize; - let obs_end = self.observable_offsets[mech_idx + 1] as usize; + let obs_start = self.dem_output_offsets[mech_idx] as usize; + let obs_end = self.dem_output_offsets[mech_idx + 1] as usize; // Use precomputed 1/ln(1-p) for geometric sampling let inv_log = self.inv_log_1_minus_p[mech_idx]; @@ -1115,7 +1478,7 @@ impl DemSampler { det_columns[d as usize][word_idx] ^= mask; } - for &o in &self.observable_data[obs_start..obs_end] { + for &o in &self.dem_output_data[obs_start..obs_end] { obs_columns[o as usize][word_idx] ^= mask; } @@ -1158,23 +1521,25 @@ impl DemSampler { /// /// Used to decide between geometric (low p) and SIMD (high p) sampling. #[must_use] - #[allow(clippy::cast_precision_loss)] pub fn average_error_probability(&self) -> f64 { if self.thresholds.is_empty() { return 0.0; } - let sum: u128 = self.thresholds.iter().map(|&t| u128::from(t)).sum(); - let avg_threshold = (sum / self.thresholds.len() as u128) as f64; - avg_threshold / u64::MAX as f64 + let mut sum = 0.0; + let mut count = 0.0; + for &threshold in &self.thresholds { + sum += threshold_to_probability(threshold); + count += 1.0; + } + sum / count } /// Maximum error probability across all mechanisms. #[must_use] - #[allow(clippy::cast_precision_loss)] pub fn max_error_probability(&self) -> f64 { self.thresholds .iter() - .map(|&t| t as f64 / u64::MAX as f64) + .map(|&t| threshold_to_probability(t)) .fold(0.0, f64::max) } @@ -1221,11 +1586,18 @@ impl DemSampler { .collect(); // Sum up partial statistics - let mut total = SamplingStatistics::new(num_shots); + let mut total = + SamplingStatistics::with_channels(num_shots, self.num_detectors, self.num_dem_outputs); for stats in partial_stats { total.syndrome_count += stats.syndrome_count; total.logical_error_count += stats.logical_error_count; total.undetectable_count += stats.undetectable_count; + for (t, s) in total.per_detector.iter_mut().zip(&stats.per_detector) { + *t += s; + } + for (t, s) in total.per_dem_output.iter_mut().zip(&stats.per_dem_output) { + *t += s; + } } total @@ -1249,30 +1621,75 @@ impl DemSampler { det_columns: &[Vec], obs_columns: &[Vec], num_shots: usize, + ) -> SamplingStatistics { + let observable_indices: Vec = (0..obs_columns.len()).collect(); + Self::compute_statistics_from_columns_for_observables( + det_columns, + obs_columns, + num_shots, + &observable_indices, + ) + } + + fn compute_statistics_from_columns_for_observables( + det_columns: &[Vec], + obs_columns: &[Vec], + num_shots: usize, + observable_indices: &[usize], ) -> SamplingStatistics { let num_words = num_shots.div_ceil(BITS_PER_WORD); - let mut stats = SamplingStatistics::new(num_shots); + let mut stats = + SamplingStatistics::with_channels(num_shots, det_columns.len(), obs_columns.len()); - // OR all detector columns to get syndrome mask + // Per-channel and aggregate syndrome let mut syndrome_words = vec![0u64; num_words]; - for col in det_columns { + for (det_idx, col) in det_columns.iter().enumerate() { + let mut count = 0usize; for (i, &word) in col.iter().enumerate() { syndrome_words[i] |= word; + let valid = if i == num_words - 1 { + let r = num_shots % BITS_PER_WORD; + if r == 0 { !0u64 } else { (1u64 << r) - 1 } + } else { + !0u64 + }; + count += (word & valid).count_ones() as usize; } + stats.per_detector[det_idx] = count; } - // OR all observable columns to get logical error mask - let mut logical_words = vec![0u64; num_words]; - for col in obs_columns { + // Per-channel DEM-output counts. + let mut dem_output_words = vec![vec![0u64; num_words]; obs_columns.len()]; + for (obs_idx, col) in obs_columns.iter().enumerate() { + let mut count = 0usize; for (i, &word) in col.iter().enumerate() { - logical_words[i] |= word; + dem_output_words[obs_idx][i] = word; + let valid = if i == num_words - 1 { + let r = num_shots % BITS_PER_WORD; + if r == 0 { !0u64 } else { (1u64 << r) - 1 } + } else { + !0u64 + }; + count += (word & valid).count_ones() as usize; + } + stats.per_dem_output[obs_idx] = count; + } + + // Aggregate logical-error mask from observables only. Tracked Paulis + // remain in per_dem_output but do not define decoder failures. + let mut observable_words = vec![0u64; num_words]; + for &obs_idx in observable_indices { + if let Some(col) = dem_output_words.get(obs_idx) { + for (i, &word) in col.iter().enumerate() { + observable_words[i] |= word; + } } } - // Count shots with syndrome, logical error, undetectable + // Aggregate counts for word_idx in 0..num_words { let syndrome = syndrome_words[word_idx]; - let logical = logical_words[word_idx]; + let observable = observable_words[word_idx]; let valid_bits = if word_idx == num_words - 1 { let remaining = num_shots % BITS_PER_WORD; @@ -1286,11 +1703,12 @@ impl DemSampler { }; let syndrome_masked = syndrome & valid_bits; - let logical_masked = logical & valid_bits; + let observable_masked = observable & valid_bits; stats.syndrome_count += syndrome_masked.count_ones() as usize; - stats.logical_error_count += logical_masked.count_ones() as usize; - stats.undetectable_count += (logical_masked & !syndrome_masked).count_ones() as usize; + stats.logical_error_count += observable_masked.count_ones() as usize; + stats.undetectable_count += + (observable_masked & !syndrome_masked).count_ones() as usize; } stats @@ -1302,12 +1720,16 @@ impl DemSampler { pub struct SamplingStatistics { /// Total number of shots. pub total_shots: usize, - /// Shots with at least one logical error. + /// Shots with at least one selected observable flip. pub logical_error_count: usize, /// Shots with at least one detector firing. pub syndrome_count: usize, - /// Shots with logical error but no syndrome (undetectable errors). + /// Shots with an observable flip but no syndrome. pub undetectable_count: usize, + /// Per-detector firing counts (shots where this detector fired). + pub per_detector: Vec, + /// Per-`L` DEM-output flip counts (shots where this `L` output flipped). + pub per_dem_output: Vec, } impl SamplingStatistics { @@ -1317,52 +1739,119 @@ impl SamplingStatistics { logical_error_count: 0, syndrome_count: 0, undetectable_count: 0, + per_detector: Vec::new(), + per_dem_output: Vec::new(), + } + } + + fn with_channels(total_shots: usize, num_detectors: usize, num_dem_outputs: usize) -> Self { + Self { + total_shots, + logical_error_count: 0, + syndrome_count: 0, + undetectable_count: 0, + per_detector: vec![0; num_detectors], + per_dem_output: vec![0; num_dem_outputs], } } /// Logical error rate. #[must_use] - #[allow(clippy::cast_precision_loss)] // rate calculation + #[allow(clippy::cast_precision_loss)] pub fn logical_error_rate(&self) -> f64 { self.logical_error_count as f64 / self.total_shots as f64 } /// Syndrome rate (fraction of shots with non-trivial syndrome). #[must_use] - #[allow(clippy::cast_precision_loss)] // rate calculation + #[allow(clippy::cast_precision_loss)] pub fn syndrome_rate(&self) -> f64 { self.syndrome_count as f64 / self.total_shots as f64 } /// Undetectable error rate. #[must_use] - #[allow(clippy::cast_precision_loss)] // rate calculation + #[allow(clippy::cast_precision_loss)] pub fn undetectable_rate(&self) -> f64 { self.undetectable_count as f64 / self.total_shots as f64 } + + /// Per-detector firing rates. + #[must_use] + #[allow(clippy::cast_precision_loss)] + pub fn detector_rates(&self) -> Vec { + let n = self.total_shots as f64; + self.per_detector.iter().map(|&c| c as f64 / n).collect() + } + + /// Per-`L` DEM-output flip rates. + #[must_use] + #[allow(clippy::cast_precision_loss)] + pub fn dem_output_rates(&self) -> Vec { + let n = self.total_shots as f64; + self.per_dem_output.iter().map(|&c| c as f64 / n).collect() + } + + /// Per-`L` DEM-output flip counts. + #[must_use] + pub fn dem_output_counts(&self) -> &[usize] { + &self.per_dem_output + } + + /// Per-observable flip counts selected from standard `L` observable columns. + #[must_use] + pub fn observable_counts(&self, observable_indices: &[usize]) -> Vec { + observable_indices + .iter() + .filter_map(|&idx| self.per_dem_output.get(idx).copied()) + .collect() + } + + /// Per-observable flip rates selected from standard `L` observable columns. + #[must_use] + #[allow(clippy::cast_precision_loss)] + pub fn logical_rates(&self, observable_indices: &[usize]) -> Vec { + let n = self.total_shots as f64; + self.observable_counts(observable_indices) + .into_iter() + .map(|c| c as f64 / n) + .collect() + } } // ============================================================================ // DEM Sampler Builder // ============================================================================ -/// Builder for [`DemSampler`]. +/// Builder for [`SamplingEngine`]. /// -/// Constructs a [`DemSampler`] from a fault influence map, noise parameters, -/// and explicit detector/observable definitions. -pub struct DemSamplerBuilder<'a> { +/// Constructs a [`SamplingEngine`] from a fault influence map, noise parameters, +/// and explicit detector/standard observable `L` definitions. +pub(crate) struct SamplingEngineBuilder<'a> { + /// Optional per-gate-type noise specification. If set, overrides the + /// uniform scalar `p1, p2` for any gate type present in its maps. + /// Measurement / prep errors come from the scalar `p_meas` / `p_prep` + /// or the per-qubit overrides in the per-gate spec. + per_gate: Option, influence_map: &'a DagFaultInfluenceMap, p1: f64, p2: f64, p_meas: f64, - p_init: f64, + p_prep: f64, + idle_noise: Option, detector_records: Vec>, observable_records: Vec>, measurement_order: Option>, num_tc_measurements: Option, } -impl<'a> DemSamplerBuilder<'a> { +struct FaultMechanismContext<'a> { + im_to_tc: Option<&'a [usize]>, + influence_observable_ids: &'a BTreeSet, + num_tc_measurements: usize, +} + +impl<'a> SamplingEngineBuilder<'a> { /// Create a new builder from an influence map. #[must_use] pub fn new(influence_map: &'a DagFaultInfluenceMap) -> Self { @@ -1371,7 +1860,9 @@ impl<'a> DemSamplerBuilder<'a> { p1: 0.01, p2: 0.01, p_meas: 0.01, - p_init: 0.01, + p_prep: 0.01, + idle_noise: None, + per_gate: None, detector_records: Vec::new(), observable_records: Vec::new(), measurement_order: None, @@ -1379,13 +1870,42 @@ impl<'a> DemSamplerBuilder<'a> { } } - /// Set noise parameters. + /// Set uniform-depolarizing noise parameters. #[must_use] - pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_init: f64) -> Self { + pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { self.p1 = p1; self.p2 = p2; self.p_meas = p_meas; - self.p_init = p_init; + self.p_prep = p_prep; + self + } + + /// Set idle gate noise rate. + #[must_use] + pub fn with_idle_noise(mut self, p_idle: f64) -> Self { + self.idle_noise = Some(NoiseConfig::new(0.0, 0.0, 0.0, 0.0).set_idle(p_idle)); + self + } + + /// Set the full idle-noise model for idle gates. + #[must_use] + pub fn with_idle_noise_config(mut self, noise: NoiseConfig) -> Self { + self.idle_noise = Some(noise); + self + } + + /// Set per-gate-type per-Pauli noise specification. When provided, + /// overrides the uniform `p1, p2` for any gate type present in the + /// spec's maps. Measurement / prep fault rates come from + /// `p_meas, p_init` on the [`PerGateTypeNoise`] struct. + /// + /// Intended consumer: `pecos-lindblad::PauliLindbladModel` via + /// per-gate-type adapter helpers. + #[must_use] + pub fn with_per_gate_noise(mut self, cfg: PerGateTypeNoise) -> Self { + self.p_meas = cfg.p_meas; + self.p_prep = cfg.p_init; + self.per_gate = Some(cfg); self } @@ -1418,7 +1938,7 @@ impl<'a> DemSamplerBuilder<'a> { self } - /// Set observable records directly. + /// Set observable definitions directly. #[must_use] pub fn with_observable_records(mut self, records: Vec>) -> Self { self.observable_records = records; @@ -1436,16 +1956,23 @@ impl<'a> DemSamplerBuilder<'a> { self } - /// Build the [`DemSampler`]. + /// Build the [`SamplingEngine`]. #[must_use] - pub fn build(self) -> DemSampler { + pub fn build(self) -> SamplingEngine { let num_detectors = self.detector_records.len(); - let num_observables = self.observable_records.len(); + let influence_observable_ids = self.influence_map.observable_ids(); + let num_influence_observables = self.influence_map.num_observables(); + let num_dem_outputs = num_influence_observables.max(self.observable_records.len()); let num_im_measurements = self.influence_map.measurements.len(); let num_tc_measurements = self.num_tc_measurements.unwrap_or(num_im_measurements); // Build IM -> TC index mapping let im_to_tc = self.build_im_to_tc_mapping(); + let mechanism_context = FaultMechanismContext { + im_to_tc: im_to_tc.as_deref(), + influence_observable_ids: &influence_observable_ids, + num_tc_measurements, + }; // Aggregation map: mechanism -> probability let mut aggregated: BTreeMap = BTreeMap::new(); @@ -1458,31 +1985,43 @@ impl<'a> DemSamplerBuilder<'a> { match loc.gate_type { GateType::PZ | GateType::QAlloc // Prep errors: only "after" locations (X error for Z-basis prep) - if self.p_init > 0.0 && !loc.before => { + if !loc.before => + { + let p = self.init_rate_for_location(loc); + if p > 0.0 { self.process_single_pauli_fault( loc_idx, Pauli::X, - self.p_init, - im_to_tc.as_deref(), - num_tc_measurements, + p, + &mechanism_context, &mut aggregated, ); } + } GateType::MZ | GateType::MeasureFree // Measurement errors: only "before" locations (X error = bit flip) - if self.p_meas > 0.0 && loc.before => { + if loc.before => + { + let p = self.measurement_rate_for_location(loc); + if p > 0.0 { self.process_single_pauli_fault( loc_idx, Pauli::X, - self.p_meas, - im_to_tc.as_deref(), - num_tc_measurements, + p, + &mechanism_context, &mut aggregated, ); } + } GateType::CX | GateType::CZ | GateType::CY + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg | GateType::SWAP | GateType::RXX | GateType::RYY @@ -1492,6 +2031,8 @@ impl<'a> DemSamplerBuilder<'a> { cx_groups.entry(loc.node).or_default().push(loc_idx); } GateType::H + | GateType::F + | GateType::Fdg | GateType::SZ | GateType::SZdg | GateType::SX @@ -1509,30 +2050,68 @@ impl<'a> DemSamplerBuilder<'a> { | GateType::U | GateType::R1XY // Single-qubit gate errors: only "after" locations, depolarizing - if self.p1 > 0.0 && !loc.before => { - self.process_depolarizing_fault( + if !loc.before => + { + let rates = self.rates_1q(loc.gate_type, &loc.qubits); + if rates.iter().any(|r| *r > 0.0) { + self.process_depolarizing_fault_rates( loc_idx, - self.p1, - im_to_tc.as_deref(), - num_tc_measurements, + rates, + &mechanism_context, &mut aggregated, ); } + } + GateType::Idle + // Idle gate errors: only "after" locations. Idle is + // noiseless unless idle noise or per-gate Idle rates are + // explicitly configured. + if !loc.before => + { + let rates = self.idle_rates(loc); + if rates.iter().any(|r| *r > 0.0) { + self.process_depolarizing_fault_rates( + loc_idx, + rates, + &mechanism_context, + &mut aggregated, + ); + } + } _ => {} } } // Process two-qubit gates as pairs - if self.p2 > 0.0 { + let has_any_2q_noise = self.per_gate.is_some() || self.p2 > 0.0; + if has_any_2q_noise { for loc_indices in cx_groups.values() { - if loc_indices.len() == 2 { - self.process_two_qubit_fault( - loc_indices[0], - loc_indices[1], - im_to_tc.as_deref(), - num_tc_measurements, - &mut aggregated, - ); + for pair in loc_indices.chunks(2) { + if pair.len() != 2 { + continue; + } + // For 2Q gates, each fault location covers exactly one + // qubit; combine the two locations' qubits into an + // ordered (control, target) pair. + let loc0 = &self.influence_map.locations[pair[0]]; + let loc1 = &self.influence_map.locations[pair[1]]; + let gate_type = loc0.gate_type; + let pair_qubits: Vec<_> = loc0 + .qubits + .iter() + .chain(loc1.qubits.iter()) + .copied() + .collect(); + let rates = self.rates_2q(gate_type, &pair_qubits); + if rates.iter().any(|r| *r > 0.0) { + self.process_two_qubit_fault_rates( + pair[0], + pair[1], + rates, + &mechanism_context, + &mut aggregated, + ); + } } } } @@ -1542,11 +2121,11 @@ impl<'a> DemSamplerBuilder<'a> { let mut thresholds = Vec::with_capacity(num_mechanisms); let mut detector_offsets = Vec::with_capacity(num_mechanisms + 1); let mut detector_data = Vec::new(); - let mut observable_offsets = Vec::with_capacity(num_mechanisms + 1); - let mut observable_data = Vec::new(); + let mut dem_output_offsets = Vec::with_capacity(num_mechanisms + 1); + let mut dem_output_data = Vec::new(); detector_offsets.push(0); - observable_offsets.push(0); + dem_output_offsets.push(0); let mut inv_log_1_minus_p = Vec::with_capacity(num_mechanisms); @@ -1575,20 +2154,20 @@ impl<'a> DemSamplerBuilder<'a> { #[allow(clippy::cast_possible_truncation)] // detector data length fits in u32 detector_offsets.push(detector_data.len() as u32); - observable_data.extend_from_slice(&mech.observables); - #[allow(clippy::cast_possible_truncation)] // observable data length fits in u32 - observable_offsets.push(observable_data.len() as u32); + dem_output_data.extend_from_slice(&mech.dem_outputs); + #[allow(clippy::cast_possible_truncation)] // `L` target data length fits in u32 + dem_output_offsets.push(dem_output_data.len() as u32); } - DemSampler { + SamplingEngine { thresholds, inv_log_1_minus_p, detector_offsets, detector_data, - observable_offsets, - observable_data, + dem_output_offsets, + dem_output_data, num_detectors, - num_observables, + num_dem_outputs, } } @@ -1631,29 +2210,142 @@ impl<'a> DemSamplerBuilder<'a> { loc_idx: usize, pauli: Pauli, prob: f64, - im_to_tc: Option<&[usize]>, - num_tc_measurements: usize, + context: &FaultMechanismContext<'_>, aggregated: &mut BTreeMap, ) { - let mechanism = self.compute_mechanism(loc_idx, pauli, im_to_tc, num_tc_measurements); + let mechanism = self.compute_mechanism( + loc_idx, + pauli, + context.im_to_tc, + context.influence_observable_ids, + context.num_tc_measurements, + ); if !mechanism.is_empty() { let entry = aggregated.entry(mechanism).or_insert(0.0); *entry = combine_probabilities(*entry, prob); } } - /// Process a depolarizing fault (X, Y, Z each with prob/3). - fn process_depolarizing_fault( + /// Resolve the X-error rate for a prep location. Uses `per_gate`'s + /// per-qubit `init_rates` if set, otherwise the scalar `self.p_prep`. + fn init_rate_for_location( + &self, + loc: &super::super::propagator::dag::DagSpacetimeLocation, + ) -> f64 { + if let Some(pg) = &self.per_gate + && let Some(q) = loc.qubits.first() + { + return pg.init_rate_on(*q); + } + self.p_prep + } + + /// Resolve the X-flip rate for a measurement location. Uses + /// `per_gate`'s per-qubit `measurement_rates` if set, otherwise the + /// scalar `self.p_meas`. + fn measurement_rate_for_location( + &self, + loc: &super::super::propagator::dag::DagSpacetimeLocation, + ) -> f64 { + if let Some(pg) = &self.per_gate + && let Some(q) = loc.qubits.first() + { + return pg.measurement_rate_on(*q); + } + self.p_meas + } + + /// Resolve per-Pauli rates for a 1Q gate on a specific qubit. Uses + /// `per_gate`'s per-qubit map if set, falling back to per-gate-type, + /// then uniform `p1 / 3`. + fn rates_1q(&self, gate: GateType, qubits: &[pecos_core::QubitId]) -> [f64; 3] { + if let Some(pg) = &self.per_gate { + if let Some(q) = qubits.first() { + [ + pg.rate_1q_on(gate, *q, 0), + pg.rate_1q_on(gate, *q, 1), + pg.rate_1q_on(gate, *q, 2), + ] + } else { + [ + pg.rate_1q(gate, 0), + pg.rate_1q(gate, 1), + pg.rate_1q(gate, 2), + ] + } + } else { + [self.p1 / 3.0; 3] + } + } + + /// Resolve per-Pauli rates for an explicit idle location. + fn idle_rates( + &self, + loc: &crate::fault_tolerance::propagator::dag::DagSpacetimeLocation, + ) -> [f64; 3] { + if let Some(pg) = &self.per_gate { + let explicit_rates = loc + .qubits + .first() + .and_then(|q| pg.explicit_1q_rates_on(GateType::Idle, *q)) + .or_else(|| pg.explicit_1q_rates(GateType::Idle)); + if let Some(rates) = explicit_rates { + return rates; + } + if pg.base.uses_dedicated_idle_noise() { + #[allow(clippy::cast_precision_loss)] + let duration = loc.idle_duration.max(1) as f64; + let probs = pg.base.idle_pauli_probs(duration); + return [probs.px, probs.py, probs.pz]; + } + return [0.0; 3]; + } + + 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 probs = noise.idle_pauli_probs(duration); + return [probs.px, probs.py, probs.pz]; + } + [0.0; 3] + } + + /// Resolve per-Pauli-pair rates for a 2Q gate (15 non-II pairs) on a + /// specific ordered qubit pair. + fn rates_2q(&self, gate: GateType, qubits: &[pecos_core::QubitId]) -> [f64; 15] { + if let Some(pg) = &self.per_gate { + if qubits.len() >= 2 { + let (qc, qt) = (qubits[0], qubits[1]); + std::array::from_fn(|i| pg.rate_2q_on(gate, qc, qt, i)) + } else { + std::array::from_fn(|i| pg.rate_2q(gate, i)) + } + } else { + [self.p2 / 15.0; 15] + } + } + + /// Process a 1Q depolarizing-family fault with explicit per-Pauli rates. + fn process_depolarizing_fault_rates( &self, loc_idx: usize, - prob: f64, - im_to_tc: Option<&[usize]>, - num_tc_measurements: usize, + rates: [f64; 3], + context: &FaultMechanismContext<'_>, aggregated: &mut BTreeMap, ) { - let per_pauli_prob = prob / 3.0; - for pauli in [Pauli::X, Pauli::Y, Pauli::Z] { - let mechanism = self.compute_mechanism(loc_idx, pauli, im_to_tc, num_tc_measurements); + for (pauli, &per_pauli_prob) in [Pauli::X, Pauli::Y, Pauli::Z].iter().zip(rates.iter()) { + if per_pauli_prob == 0.0 { + continue; + } + let mechanism = self.compute_mechanism( + loc_idx, + *pauli, + context.im_to_tc, + context.influence_observable_ids, + context.num_tc_measurements, + ); if !mechanism.is_empty() { let entry = aggregated.entry(mechanism).or_insert(0.0); *entry = combine_probabilities(*entry, per_pauli_prob); @@ -1661,53 +2353,63 @@ impl<'a> DemSamplerBuilder<'a> { } } - /// Process a two-qubit gate fault (15 non-identity Pauli combinations with p2/15 each). - fn process_two_qubit_fault( + /// Process a two-qubit gate fault with explicit per-Pauli-pair rates. + /// `rates[i]` corresponds to [`PAULI_2Q_ORDER[i]`] ordering. + fn process_two_qubit_fault_rates( &self, loc1: usize, loc2: usize, - im_to_tc: Option<&[usize]>, - num_tc_measurements: usize, + rates: [f64; 15], + context: &FaultMechanismContext<'_>, aggregated: &mut BTreeMap, ) { - let prob = self.p2 / 15.0; let paulis = [Pauli::I, Pauli::X, Pauli::Y, Pauli::Z]; - // Cache single-qubit mechanisms for each Pauli on each location let mut effects1: [Option; 4] = [None, None, None, None]; let mut effects2: [Option; 4] = [None, None, None, None]; for &p in &[Pauli::X, Pauli::Y, Pauli::Z] { - effects1[p as usize] = - Some(self.compute_mechanism(loc1, p, im_to_tc, num_tc_measurements)); - effects2[p as usize] = - Some(self.compute_mechanism(loc2, p, im_to_tc, num_tc_measurements)); + effects1[p as usize] = Some(self.compute_mechanism( + loc1, + p, + context.im_to_tc, + context.influence_observable_ids, + context.num_tc_measurements, + )); + effects2[p as usize] = Some(self.compute_mechanism( + loc2, + p, + context.im_to_tc, + context.influence_observable_ids, + context.num_tc_measurements, + )); } - // Process all 15 non-trivial Pauli combinations + // Iterate (p1, p2) with global index = 4*p1 + p2 (skipping II at idx 0). for &p1 in &paulis { for &p2 in &paulis { if p1 == Pauli::I && p2 == Pauli::I { - continue; // Skip II + continue; + } + let flat = 4 * (p1 as usize) + (p2 as usize); + let prob = rates[flat - 1]; + if prob == 0.0 { + continue; } - let mechanism = if p1 == Pauli::I { - // IX, IY, IZ - only second qubit effects2[p2 as usize] .clone() .unwrap_or_else(DemMechanism::empty) } else if p2 == Pauli::I { - // XI, YI, ZI - only first qubit effects1[p1 as usize] .clone() .unwrap_or_else(DemMechanism::empty) } else { - // Correlated: XOR the detector/observable effects + // Correlated: XOR the detector/standard observable effects let e1 = effects1[p1 as usize].as_ref(); let e2 = effects2[p2 as usize].as_ref(); xor_mechanisms(e1, e2) }; - if !mechanism.is_empty() { let entry = aggregated.entry(mechanism).or_insert(0.0); *entry = combine_probabilities(*entry, prob); @@ -1716,12 +2418,13 @@ impl<'a> DemSamplerBuilder<'a> { } } - /// Compute the mechanism (detector/observable effects) for a fault. + /// Compute the mechanism (detector/standard observable effects) for a fault. fn compute_mechanism( &self, loc_idx: usize, pauli: Pauli, im_to_tc: Option<&[usize]>, + influence_observable_ids: &BTreeSet, num_tc_measurements: usize, ) -> DemMechanism { // Get measurement indices that flip (in IM order) @@ -1729,6 +2432,14 @@ impl<'a> DemSamplerBuilder<'a> { .influence_map .get_detector_indices(loc_idx, pauli as u8); + let mut dem_outputs: SmallVec<[u32; 2]> = SmallVec::new(); + for dem_output_idx in self + .influence_map + .get_observable_indices(loc_idx, pauli as u8) + { + xor_toggle_u32(&mut dem_outputs, dem_output_idx); + } + // Convert to TC order measurement outcomes let mut tc_outcomes = vec![false; num_tc_measurements]; for &im_idx in im_meas_flips { @@ -1776,48 +2487,59 @@ impl<'a> DemSamplerBuilder<'a> { }) .collect(); - // Apply observable definitions (XOR of measurement outcomes) - let observables: SmallVec<[u32; 2]> = self - .observable_records - .iter() - .enumerate() - .filter_map(|(obs_id, records)| { - let mut xor_result = false; - for &offset in records { - #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)] // measurement count fits in i32 - #[allow(clippy::cast_sign_loss)] - // negative offset + total count, or non-negative offset - let abs_idx = if offset < 0 { - (num_tc_measurements as i32 + offset) as usize - } else { - offset as usize - }; - if abs_idx < num_tc_measurements && tc_outcomes[abs_idx] { - xor_result = !xor_result; - } - } - if xor_result { - #[allow(clippy::cast_possible_truncation)] // observable ID fits in u32 - Some(obs_id as u32) + // Apply standard observable `L` definitions (XOR of measurement outcomes) + for (obs_id, records) in self.observable_records.iter().enumerate() { + #[allow(clippy::cast_possible_truncation)] // observable ID fits in u32 + let obs_id_u32 = obs_id as u32; + if influence_observable_ids.contains(&obs_id_u32) { + continue; + } + let mut xor_result = false; + for &offset in records { + #[allow(clippy::cast_possible_wrap, clippy::cast_possible_truncation)] // measurement count fits in i32 + #[allow(clippy::cast_sign_loss)] + // negative offset + total count, or non-negative offset + let abs_idx = if offset < 0 { + (num_tc_measurements as i32 + offset) as usize } else { - None + offset as usize + }; + if abs_idx < num_tc_measurements && tc_outcomes[abs_idx] { + xor_result = !xor_result; } - }) - .collect(); + } + if xor_result { + #[allow(clippy::cast_possible_truncation)] // `L` target ID fits in u32 + xor_toggle_u32(&mut dem_outputs, obs_id_u32); + } + } + dem_outputs.sort_unstable(); + + DemMechanism::new(detectors, dem_outputs) + } +} - DemMechanism::new(detectors, observables) +/// Toggles an element in a parity vector. +fn xor_toggle_u32(values: &mut SmallVec<[u32; N]>, value: u32) +where + [u32; N]: smallvec::Array, +{ + if let Some(pos) = values.iter().position(|&v| v == value) { + values.remove(pos); + } else { + values.push(value); } } -/// XORs two [`DemMechanism`]s (symmetric difference of detectors and observables). +/// XORs two [`DemMechanism`]s (symmetric difference of detectors and standard observables). fn xor_mechanisms(a: Option<&DemMechanism>, b: Option<&DemMechanism>) -> DemMechanism { match (a, b) { (Some(m1), Some(m2)) => { let detectors = xor_u32_vecs::<4>(&m1.detectors, &m2.detectors); - let observables = xor_u32_vecs::<2>(&m1.observables, &m2.observables); + let dem_outputs = xor_u32_vecs::<2>(&m1.dem_outputs, &m2.dem_outputs); DemMechanism { detectors, - observables, + dem_outputs, } } (Some(m), None) | (None, Some(m)) => m.clone(), @@ -1857,7 +2579,7 @@ where result } -/// Parse detector or observable records from JSON. +/// Parse detector or observable definitions from JSON. /// /// Uses a simple custom parser to avoid `serde_json` dependency. /// Expected format: `[{"id": 0, "records": [-1, -5]}, ...]` @@ -1975,8 +2697,8 @@ mod tests { // Detectors: {0,1,2} XOR {1,2,3} = {0,3} assert_eq!(result.detectors.as_slice(), &[0, 3]); - // Observables: {0} XOR {0,1} = {1} - assert_eq!(result.observables.as_slice(), &[1]); + // DEM outputs: {0} XOR {0,1} = {1} + assert_eq!(result.dem_outputs.as_slice(), &[1]); } #[test] @@ -2030,11 +2752,13 @@ mod tests { let influence_map = analyzer.build_influence_map(); // Zero noise should produce no errors - let sampler = DemSamplerBuilder::new(&influence_map) + let sampler = SamplingEngineBuilder::new(&influence_map) .with_noise(0.0, 0.0, 0.0, 0.0) .build(); assert_eq!(sampler.num_mechanisms(), 0); + assert_eq!(sampler.num_dem_outputs(), 0); + assert_eq!(sampler.num_observables(), 0); let stats = sampler.sample_statistics(100, 42); assert_eq!(stats.logical_error_count, 0); @@ -2060,7 +2784,7 @@ mod tests { let detectors_json = r#"[{"id": 0, "records": [-1]}]"#; let observables_json = r"[]"; - let sampler = DemSamplerBuilder::new(&influence_map) + let sampler = SamplingEngineBuilder::new(&influence_map) .with_noise(0.1, 0.1, 0.1, 0.1) .with_detectors_json(detectors_json) .unwrap() @@ -2077,6 +2801,36 @@ mod tests { assert!(stats.syndrome_count > 0); } + #[test] + fn test_sampling_engine_builder_accepts_observable_record_inputs() { + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + use pecos_quantum::DagCircuit; + + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.h(&[0]); + dag.mz(&[0]); + + let analyzer = DagFaultAnalyzer::new(&dag); + let influence_map = analyzer.build_influence_map(); + + let json_sampler = SamplingEngineBuilder::new(&influence_map) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .with_observables_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + assert_eq!(json_sampler.num_detectors(), 1); + assert_eq!(json_sampler.num_dem_outputs(), 1); + + let record_sampler = SamplingEngineBuilder::new(&influence_map) + .with_detector_records(vec![vec![-1]]) + .with_observable_records(vec![vec![-1]]) + .build(); + assert_eq!(record_sampler.num_detectors(), 1); + assert_eq!(record_sampler.num_dem_outputs(), 1); + } + #[test] fn test_columnar_sampling_statistics() { use crate::fault_tolerance::propagator::DagFaultAnalyzer; @@ -2096,7 +2850,7 @@ mod tests { let analyzer = DagFaultAnalyzer::new(&dag); let influence_map = analyzer.build_influence_map(); - let sampler = DemSamplerBuilder::new(&influence_map) + let sampler = SamplingEngineBuilder::new(&influence_map) .with_noise(0.01, 0.01, 0.01, 0.01) .with_detector_records(vec![vec![-1], vec![-2]]) .with_observable_records(vec![]) @@ -2140,7 +2894,7 @@ mod tests { let analyzer = DagFaultAnalyzer::new(&dag); let influence_map = analyzer.build_influence_map(); - let sampler = DemSamplerBuilder::new(&influence_map) + let sampler = SamplingEngineBuilder::new(&influence_map) .with_noise(0.5, 0.0, 0.0, 0.0) // High noise rate for testing .with_detector_records(vec![vec![-1]]) .with_observable_records(vec![]) @@ -2176,7 +2930,7 @@ mod tests { let analyzer = DagFaultAnalyzer::new(&dag); let influence_map = analyzer.build_influence_map(); - let sampler = DemSamplerBuilder::new(&influence_map) + let sampler = SamplingEngineBuilder::new(&influence_map) .with_noise(0.01, 0.01, 0.01, 0.01) .with_detector_records(vec![vec![-1], vec![-2]]) .with_observable_records(vec![]) @@ -2217,7 +2971,7 @@ mod tests { let influence_map = analyzer.build_influence_map(); // Use low noise to exercise geometric sampling effectively - let sampler = DemSamplerBuilder::new(&influence_map) + let sampler = SamplingEngineBuilder::new(&influence_map) .with_noise(0.001, 0.001, 0.001, 0.001) .with_detector_records(vec![vec![-1], vec![-2]]) .with_observable_records(vec![]) @@ -2261,7 +3015,7 @@ mod tests { let influence_map = analyzer.build_influence_map(); // Low error rate - should use geometric - let sampler = DemSamplerBuilder::new(&influence_map) + let sampler = SamplingEngineBuilder::new(&influence_map) .with_noise(0.001, 0.001, 0.001, 0.001) .with_detector_records(vec![vec![-1]]) .with_observable_records(vec![]) @@ -2291,7 +3045,7 @@ mod tests { let influence_map = analyzer.build_influence_map(); // High error rate - should use SIMD - let sampler = DemSamplerBuilder::new(&influence_map) + let sampler = SamplingEngineBuilder::new(&influence_map) .with_noise(0.1, 0.1, 0.1, 0.1) .with_detector_records(vec![vec![-1]]) .with_observable_records(vec![]) @@ -2323,7 +3077,7 @@ mod tests { let analyzer = DagFaultAnalyzer::new(&dag); let influence_map = analyzer.build_influence_map(); - let sampler = DemSamplerBuilder::new(&influence_map) + let sampler = SamplingEngineBuilder::new(&influence_map) .with_noise(0.001, 0.001, 0.001, 0.001) .with_detector_records(vec![vec![-1], vec![-2]]) .with_observable_records(vec![]) @@ -2358,7 +3112,7 @@ mod tests { #[test] fn test_from_mechanisms_empty() { - let sampler = DemSampler::from_mechanisms(std::iter::empty(), 0, 0); + let sampler = SamplingEngine::from_mechanisms(std::iter::empty(), 0, 0); assert_eq!(sampler.num_mechanisms(), 0); assert_eq!(sampler.num_detectors(), 0); assert_eq!(sampler.num_observables(), 0); @@ -2372,7 +3126,7 @@ mod tests { fn test_from_mechanisms_single_detector() { // Single mechanism that flips D0 with p=0.5 let mechanisms = vec![(0.5, vec![0u32], vec![])]; - let sampler = DemSampler::from_mechanisms(mechanisms, 1, 0); + let sampler = SamplingEngine::from_mechanisms(mechanisms, 1, 0); assert_eq!(sampler.num_mechanisms(), 1); assert_eq!(sampler.num_detectors(), 1); @@ -2390,7 +3144,7 @@ mod tests { fn test_from_mechanisms_multiple_detectors() { // Two mechanisms: D0 with p=0.1, D1 with p=0.2 let mechanisms = vec![(0.1, vec![0u32], vec![]), (0.2, vec![1u32], vec![])]; - let sampler = DemSampler::from_mechanisms(mechanisms, 2, 0); + let sampler = SamplingEngine::from_mechanisms(mechanisms, 2, 0); assert_eq!(sampler.num_mechanisms(), 2); assert_eq!(sampler.num_detectors(), 2); @@ -2408,7 +3162,7 @@ mod tests { fn test_from_mechanisms_correlated_detectors() { // Single mechanism that flips both D0 and D1 together with p=0.3 let mechanisms = vec![(0.3, vec![0u32, 1u32], vec![])]; - let sampler = DemSampler::from_mechanisms(mechanisms, 2, 0); + let sampler = SamplingEngine::from_mechanisms(mechanisms, 2, 0); assert_eq!(sampler.num_mechanisms(), 1); assert_eq!(sampler.num_detectors(), 2); @@ -2427,7 +3181,7 @@ mod tests { // Two mechanisms that both flip D0 with the same probability // When both fire, they XOR and cancel let mechanisms = vec![(0.5, vec![0u32], vec![]), (0.5, vec![0u32], vec![])]; - let sampler = DemSampler::from_mechanisms(mechanisms, 1, 0); + let sampler = SamplingEngine::from_mechanisms(mechanisms, 1, 0); // With two independent p=0.5 mechanisms that both flip D0: // P(D0 fires) = P(exactly one fires) = 2 * 0.5 * 0.5 = 0.5 @@ -2440,15 +3194,21 @@ mod tests { } #[test] - fn test_from_mechanisms_with_observables() { + fn test_from_mechanisms_with_tracked_paulis() { // Mechanism that flips D0 and L0 let mechanisms = vec![(0.1, vec![0u32], vec![0u32])]; - let sampler = DemSampler::from_mechanisms(mechanisms, 1, 1); + let sampler = SamplingEngine::from_mechanisms(mechanisms, 1, 1); - assert_eq!(sampler.num_observables(), 1); + assert_eq!(sampler.num_dem_outputs(), 1); // Logical error rate should be approximately 0.1 let stats = sampler.sample_statistics(10000, 42); + assert_eq!(stats.dem_output_counts(), stats.per_dem_output.as_slice()); + assert_eq!( + stats.observable_counts(&[0]).as_slice(), + stats.per_dem_output.as_slice() + ); + assert_eq!(stats.logical_rates(&[0]), stats.dem_output_rates()); let logical_rate = stats.logical_error_rate(); assert!( (logical_rate - 0.1).abs() < 0.03, @@ -2456,11 +3216,30 @@ mod tests { ); } + #[test] + fn test_selected_observable_statistics_ignore_unselected_tracked_outputs() { + // L0 is the measured observable column; L1 represents a tracked + // operator column. The mechanism flips only L1, so observable-only + // logical statistics for L0 must stay zero while per-output counts + // still report the tracked-column flips. + let mechanisms = vec![(1.0, vec![], vec![1u32])]; + let sampler = SamplingEngine::from_mechanisms(mechanisms, 0, 2); + + let stats = sampler.sample_statistics_for_observable_indices(32, 42, &[0]); + + assert_eq!(stats.total_shots, 32); + assert_eq!(stats.per_dem_output, vec![0, 32]); + assert_eq!(stats.logical_error_count, 0); + assert_eq!(stats.undetectable_count, 0); + assert_eq!(stats.observable_counts(&[0]), vec![0]); + assert_eq!(stats.observable_counts(&[1]), vec![32]); + } + #[test] fn test_from_mechanisms_very_low_error_rate() { // Test geometric sampling efficiency with low error rate let mechanisms = vec![(0.0001, vec![0u32], vec![])]; - let sampler = DemSampler::from_mechanisms(mechanisms, 1, 0); + let sampler = SamplingEngine::from_mechanisms(mechanisms, 1, 0); // Should still work correctly let stats = sampler.sample_statistics(100_000, 42); @@ -2475,11 +3254,12 @@ mod tests { fn test_from_mechanisms_sorting() { // Verify that detector indices are sorted regardless of input order let mechanisms = vec![(0.1, vec![2u32, 0u32, 1u32], vec![1u32, 0u32])]; - let sampler = DemSampler::from_mechanisms(mechanisms, 3, 2); + let sampler = SamplingEngine::from_mechanisms(mechanisms, 3, 2); // Verify internal storage is sorted (by checking that sampling works) assert_eq!(sampler.num_detectors(), 3); assert_eq!(sampler.num_observables(), 2); + assert_eq!(sampler.num_dem_outputs(), 2); let stats = sampler.sample_statistics(1000, 42); // Just verify it runs without panicking 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 0cc578a27..d18e60b01 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/equivalence.rs @@ -18,7 +18,7 @@ //! # Key Concepts //! //! - Two DEMs are equivalent if they produce the same probability distribution -//! over (`detector_events`, `observable_flips`) patterns. +//! 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. //! - For non-decomposed DEMs, mechanisms must match exactly. @@ -58,7 +58,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::str::FromStr; -use super::types::combine_probabilities; +use super::types::{DemOutput, combine_probabilities, parse_pecos_dem_metadata_line}; // ============================================================================ // Parsed DEM Types @@ -86,6 +86,7 @@ impl ParsedMechanism { components: vec![MechanismComponent { detectors, observables, + tracked_paulis: Vec::new(), }], } } @@ -98,9 +99,10 @@ impl ParsedMechanism { /// Returns the combined effect of this mechanism (XOR of all components). #[must_use] - pub fn combined_effect(&self) -> (Vec, Vec) { + pub fn combined_effect(&self) -> (Vec, Vec, Vec) { let mut all_dets: BTreeSet = BTreeSet::new(); let mut all_obs: BTreeSet = BTreeSet::new(); + let mut all_tracked_paulis: BTreeSet = BTreeSet::new(); for comp in &self.components { for &d in &comp.detectors { @@ -117,23 +119,55 @@ impl ParsedMechanism { all_obs.insert(o); } } + for &op in &comp.tracked_paulis { + if all_tracked_paulis.contains(&op) { + all_tracked_paulis.remove(&op); + } else { + all_tracked_paulis.insert(op); + } + } } // BTreeSet is already sorted, so just collect let dets: Vec = all_dets.into_iter().collect(); let obs: Vec = all_obs.into_iter().collect(); - (dets, obs) + let tracked_paulis: Vec = all_tracked_paulis.into_iter().collect(); + (dets, obs, tracked_paulis) } /// Creates an effect key for this mechanism (for aggregation). #[must_use] pub fn effect_key(&self) -> EffectKey { - let (dets, obs) = self.combined_effect(); + let (dets, obs, tracked_paulis) = self.combined_effect(); EffectKey { detectors: dets, observables: obs, + tracked_paulis, } } + + /// Format the target string for this mechanism (e.g., "D0 D3 L0" or "D0 ^ D1 D3"). + #[must_use] + pub fn format_targets(&self) -> String { + let parts: Vec = self + .components + .iter() + .map(|comp| { + let mut tokens = Vec::new(); + for &d in &comp.detectors { + tokens.push(format!("D{d}")); + } + for &o in &comp.observables { + tokens.push(format!("L{o}")); + } + for &op in &comp.tracked_paulis { + tokens.push(format!("TP{op}")); + } + tokens.join(" ") + }) + .collect(); + parts.join(" ^ ") + } } /// A single component of a mechanism (the part between ^ separators). @@ -143,6 +177,8 @@ pub struct MechanismComponent { pub detectors: Vec, /// Observable IDs in this component. pub observables: Vec, + /// PECOS tracked-Pauli IDs in this component. + pub tracked_paulis: Vec, } /// Key for aggregating mechanisms by their effect. @@ -152,6 +188,8 @@ pub struct EffectKey { pub detectors: Vec, /// Sorted observable IDs. pub observables: Vec, + /// Sorted tracked-Pauli IDs. + pub tracked_paulis: Vec, } impl EffectKey { @@ -163,6 +201,7 @@ impl EffectKey { Self { detectors, observables, + tracked_paulis: Vec::new(), } } } @@ -176,6 +215,9 @@ impl fmt::Display for EffectKey { for &o in &self.observables { parts.push(format!("L{o}")); } + for &op in &self.tracked_paulis { + parts.push(format!("TP{op}")); + } if parts.is_empty() { write!(f, "(empty)") } else { @@ -195,8 +237,12 @@ pub struct ParsedDem { pub mechanisms: Vec, /// Number of detectors (max ID + 1). pub num_detectors: u32, - /// Number of observables (max ID + 1). - pub num_observables: u32, + /// Total number of outputs in the DEM `L` namespace. + pub num_dem_outputs: u32, + /// PECOS metadata for `L` observables, indexed by `L`. + pub dem_outputs: Vec>, + /// PECOS metadata for tracked Paulis in their own ID space. + pub tracked_paulis: Vec>, } impl ParsedDem { @@ -206,13 +252,15 @@ impl ParsedDem { Self { mechanisms: Vec::new(), num_detectors: 0, - num_observables: 0, + num_dem_outputs: 0, + dem_outputs: Vec::new(), + tracked_paulis: Vec::new(), } } /// Parses a DEM from a string. /// - /// Supports both Stim and PECOS DEM formats. + /// Supports both standard and PECOS DEM formats. /// /// # Errors /// @@ -221,6 +269,32 @@ impl ParsedDem { dem_str.parse() } + /// Total number of outputs in the DEM `L` namespace. + #[must_use] + pub fn num_dem_outputs(&self) -> u32 { + self.num_dem_outputs + } + + /// Number of observables. + #[must_use] + pub fn num_observables(&self) -> u32 { + self.num_dem_outputs + } + + /// Number of tracked Paulis. + #[must_use] + pub fn num_tracked_paulis(&self) -> u32 { + u32::try_from(self.tracked_paulis.len()).unwrap_or(u32::MAX) + } + + fn record_metadata(ops: &mut Vec>, op: DemOutput) { + let idx = op.id as usize; + if ops.len() <= idx { + ops.resize(idx + 1, None); + } + ops[idx] = Some(op); + } + /// Parses a single error line. fn parse_error_line(line: &str) -> Result { // Extract probability: error(0.01) ... @@ -265,6 +339,7 @@ impl ParsedDem { fn parse_component(s: &str) -> Result { let mut detectors = Vec::new(); let mut observables = Vec::new(); + let mut tracked_paulis = Vec::new(); for token in s.split_whitespace() { if let Some(id_str) = token.strip_prefix('D') { @@ -277,16 +352,24 @@ impl ParsedDem { .parse() .map_err(|_| DemParseError::InvalidObservableId(token.to_string()))?; observables.push(id); + } else if let Some(id_str) = token.strip_prefix("TP") { + let id: u32 = id_str + .parse() + .map_err(|_| DemParseError::InvalidTrackedPauliId(token.to_string()))?; + tracked_paulis.push(id); + } else { + return Err(DemParseError::InvalidTarget(token.to_string())); } - // Skip unknown tokens } detectors.sort_unstable(); observables.sort_unstable(); + tracked_paulis.sort_unstable(); Ok(MechanismComponent { detectors, observables, + tracked_paulis, }) } @@ -322,7 +405,9 @@ impl ParsedDem { if mech.is_decomposed() { // For decomposed mechanisms, each component fires independently for comp in &mech.components { - let key = EffectKey::new(comp.detectors.clone(), comp.observables.clone()); + let mut key = EffectKey::new(comp.detectors.clone(), comp.observables.clone()); + key.tracked_paulis.clone_from(&comp.tracked_paulis); + key.tracked_paulis.sort_unstable(); aggregated .entry(key) .and_modify(|p| *p = combine_probabilities(*p, mech.probability)) @@ -343,17 +428,17 @@ impl ParsedDem { /// Samples from this DEM. /// - /// Returns (`detector_events`, `observable_flips`). + /// Returns (`detector_events`, `dem_output_flips`). /// /// # Semantics /// - /// In Stim's DEM format, `error(p) A ^ B` means that when the error fires + /// In DEM syntax, `error(p) A ^ B` means that when the error fires /// (with probability p), ALL components (A and B) flip together. The `^` /// separator is used for error tracking/decomposition but doesn't create /// independent firing - all components fire together as a single error. pub fn sample(&self, rng: &mut R) -> (Vec, Vec) { let mut det_events = vec![false; self.num_detectors as usize]; - let mut obs_flips = vec![false; self.num_observables as usize]; + let mut obs_flips = vec![false; self.num_dem_outputs as usize]; for mech in &self.mechanisms { // Single random check for the entire mechanism @@ -379,7 +464,7 @@ impl ParsedDem { /// Samples multiple shots from this DEM. /// - /// Returns (`detector_events_per_shot`, `observable_flips_per_shot`). + /// Returns (`detector_events_per_shot`, `dem_output_flips_per_shot`). pub fn sample_batch( &self, num_shots: usize, @@ -408,25 +493,130 @@ impl ParsedDem { /// /// # Note on decomposed errors /// - /// In Stim's DEM format, `error(p) D0 ^ D1` means that when the error fires + /// In DEM syntax, `error(p) D0 ^ D1` means that when the error fires /// (with probability p), BOTH D0 and D1 flip together. The `^` separator is /// used for error tracking/decomposition but doesn't affect sampling - all /// components fire together. #[must_use] - pub fn to_dem_sampler(&self) -> super::dem_sampler::DemSampler { - // Convert mechanisms to the format expected by DemSampler::from_mechanisms + pub fn to_dem_sampler(&self) -> super::sampler::DemSampler { + // Convert mechanisms to the format expected by SamplingEngine::from_mechanisms // Use combined_effect() to get the union of all detectors/observables // since all components fire together when the error occurs let mechanisms = self.mechanisms.iter().map(|mech| { - let (dets, obs) = mech.combined_effect(); + let (dets, obs, _tracked_paulis) = mech.combined_effect(); (mech.probability, dets, obs) }); - super::dem_sampler::DemSampler::from_mechanisms( + let engine = super::dem_sampler::SamplingEngine::from_mechanisms( mechanisms, self.num_detectors as usize, - self.num_observables as usize, - ) + self.num_dem_outputs as usize, + ); + let dem_outputs = self + .dem_outputs + .iter() + .enumerate() + .map(|(id, output)| { + output.clone().or_else(|| { + #[allow(clippy::cast_possible_truncation)] + // parsed DEM output count fits in u32 + { + Some( + DemOutput::new(id as u32) + .with_kind(crate::fault_tolerance::DemOutputKind::Observable), + ) + } + }) + }) + .collect(); + let tracked_paulis = self + .tracked_paulis + .iter() + .enumerate() + .map(|(id, output)| { + output.clone().or_else(|| { + #[allow(clippy::cast_possible_truncation)] + // parsed tracked-Pauli count fits in u32 + { + Some( + DemOutput::new(id as u32) + .with_kind(crate::fault_tolerance::DemOutputKind::TrackedPauli), + ) + } + }) + }) + .collect(); + + super::sampler::DemSampler::from_engine(engine) + .with_dem_output_metadata(dem_outputs, tracked_paulis) + } + + /// Convert to a decomposed (graphlike) DEM string. + /// + /// Mechanisms with <= 2 detectors pass through unchanged. Already-decomposed + /// mechanisms (with `^` notation) pass through unchanged. + /// + /// Hyperedges (3+ detectors, not already decomposed) cannot be decomposed + /// without Pauli provenance and will cause an error. Use + /// `coherent_dem_decomposed()` or `noise_characterization()` for proper + /// X/Z-aware decomposition. + /// + /// # Errors + /// + /// Returns an error if any mechanism has 3+ detectors without decomposition. + pub fn to_string_decomposed(&self) -> Result { + let mut lines = Vec::new(); + + // Accumulate by target string to merge identical decomposed entries + let mut by_targets: BTreeMap = BTreeMap::new(); + + for mech in &self.mechanisms { + if mech.probability <= 0.0 { + continue; + } + + if mech.is_decomposed() { + // Already decomposed — pass through + let targets = mech.format_targets(); + by_targets + .entry(targets) + .and_modify(|p| *p = combine_probabilities(*p, mech.probability)) + .or_insert(mech.probability); + continue; + } + + // Single component + let comp = &mech.components[0]; + + if comp.detectors.len() <= 2 { + // Graphlike — pass through + let targets = mech.format_targets(); + by_targets + .entry(targets) + .and_modify(|p| *p = combine_probabilities(*p, mech.probability)) + .or_insert(mech.probability); + } else { + // Hyperedge (3+ detectors) cannot be decomposed without Pauli + // provenance (X/Z component info). Use `coherent_dem_decomposed()` + // or `noise_characterization()` which track X/Z components from + // the backward mechanism extraction. + return Err(format!( + "Cannot decompose hyperedge with {} detectors ({:?}) without \ + Pauli provenance. Use coherent_dem_decomposed() or \ + noise_characterization() for proper X/Z decomposition.", + comp.detectors.len(), + comp.detectors, + )); + } + } + + for (targets, prob) in &by_targets { + if *prob > 0.0 { + lines.push(format!("error({prob}) {targets}")); + } + } + + Ok(lines.join("\n")) } } @@ -443,6 +633,9 @@ impl FromStr for ParsedDem { let mut mechanisms = Vec::new(); let mut max_det: i32 = -1; let mut max_obs: i32 = -1; + let mut max_tracked_pauli: i32 = -1; + let mut dem_outputs: Vec> = Vec::new(); + let mut tracked_paulis: Vec> = Vec::new(); for line in dem_str.lines() { let line = line.trim(); @@ -470,6 +663,12 @@ impl FromStr for ParsedDem { max_obs = max_obs.max(o as i32); } } + for &op in &comp.tracked_paulis { + #[allow(clippy::cast_possible_wrap)] // tracked-Pauli ID fits in i32 + { + max_tracked_pauli = max_tracked_pauli.max(op as i32); + } + } } mechanisms.push(mech); @@ -491,6 +690,51 @@ impl FromStr for ParsedDem { { max_obs = max_obs.max(id as i32); } + Self::record_metadata( + &mut dem_outputs, + DemOutput::new(id).with_kind(crate::fault_tolerance::DemOutputKind::Observable), + ); + } + // Parse PECOS DEM-superset metadata declarations. + else if line.starts_with("pecos_observable") + || line.starts_with("pecos_tracked_pauli") + { + let op = parse_pecos_dem_metadata_line(line) + .map_err(|err| DemParseError::InvalidPecosMetadata(err.to_string()))?; + if op.is_tracked_pauli() { + #[allow(clippy::cast_possible_wrap)] // tracked-Pauli ID fits in i32 + { + max_tracked_pauli = max_tracked_pauli.max(op.id as i32); + } + Self::record_metadata(&mut tracked_paulis, op); + } else { + #[allow(clippy::cast_possible_wrap)] // observable ID fits in i32 + { + max_obs = max_obs.max(op.id as i32); + } + Self::record_metadata(&mut dem_outputs, op); + } + } + // PECOS extensions are explicit; ordinary DEM lines remain valid, + // but unknown PECOS extension statements should not be silently + // accepted as historical aliases. + else if line.starts_with("pecos_") { + return Err(DemParseError::InvalidPecosMetadata(format!( + "unsupported PECOS DEM extension line: {line}" + ))); + } + } + + if max_obs >= 0 { + #[allow(clippy::cast_sign_loss)] // guarded by >= 0 check + { + dem_outputs.resize(max_obs as usize + 1, None); + } + } + if max_tracked_pauli >= 0 { + #[allow(clippy::cast_sign_loss)] // guarded by >= 0 check + { + tracked_paulis.resize(max_tracked_pauli as usize + 1, None); } } @@ -504,7 +748,7 @@ impl FromStr for ParsedDem { } else { 0 }, - num_observables: if max_obs >= 0 { + num_dem_outputs: if max_obs >= 0 { #[allow(clippy::cast_sign_loss)] // guarded by >= 0 check { max_obs as u32 + 1 @@ -512,6 +756,8 @@ impl FromStr for ParsedDem { } else { 0 }, + dem_outputs, + tracked_paulis, }) } } @@ -531,6 +777,12 @@ pub enum DemParseError { InvalidDetectorId(String), /// Invalid observable ID. InvalidObservableId(String), + /// Invalid tracked-Pauli ID. + InvalidTrackedPauliId(String), + /// Invalid target token in an error line. + InvalidTarget(String), + /// Invalid PECOS DEM-superset metadata. + InvalidPecosMetadata(String), } impl std::fmt::Display for DemParseError { @@ -540,6 +792,9 @@ impl std::fmt::Display for DemParseError { Self::InvalidProbability(s) => write!(f, "Invalid probability: {s}"), Self::InvalidDetectorId(s) => write!(f, "Invalid detector ID: {s}"), Self::InvalidObservableId(s) => write!(f, "Invalid observable ID: {s}"), + Self::InvalidTrackedPauliId(s) => write!(f, "Invalid tracked Pauli ID: {s}"), + Self::InvalidTarget(s) => write!(f, "Invalid DEM error target: {s}"), + Self::InvalidPecosMetadata(s) => write!(f, "Invalid PECOS DEM metadata: {s}"), } } } @@ -713,7 +968,7 @@ pub fn compare_dems_statistical( // Compute detector firing rates (marginals) let num_det = dem1.num_detectors.max(dem2.num_detectors) as usize; - let num_obs = dem1.num_observables.max(dem2.num_observables) as usize; + let num_obs = dem1.num_dem_outputs.max(dem2.num_dem_outputs) as usize; let mut det_rates1 = vec![0.0; num_det]; let mut det_rates2 = vec![0.0; num_det]; @@ -1002,6 +1257,140 @@ mod tests { assert_eq!(dem.mechanisms[0].components[0].observables, vec![0]); } + #[test] + fn test_parse_accepts_pecos_dem_superset_metadata() { + let dem_str = r#" + error(0.02) D0 TP0 + pecos_tracked_pauli {"id":0,"kind":"tracked_pauli","label":"track","pauli":"+X0 Z2","records":[]} + "#; + let dem = ParsedDem::from_str(dem_str).unwrap(); + + assert_eq!(dem.mechanisms.len(), 1); + assert_eq!(dem.num_detectors, 1); + assert_eq!(dem.num_dem_outputs(), 0); + assert_eq!(dem.num_observables(), 0); + assert_eq!(dem.num_tracked_paulis(), 1); + assert_eq!(dem.mechanisms[0].components[0].tracked_paulis, vec![0]); + assert_eq!(dem.mechanisms[0].format_targets(), "D0 TP0"); + assert_eq!(dem.mechanisms[0].effect_key().to_string(), "D0 TP0"); + let op = dem.tracked_paulis[0].as_ref().unwrap(); + assert_eq!(op.label.as_deref(), Some("track")); + assert_eq!( + op.kind, + Some(crate::fault_tolerance::DemOutputKind::TrackedPauli) + ); + assert_eq!(op.pauli.as_ref().unwrap().to_sparse_str(), "+X0 Z2"); + } + + #[test] + fn test_parse_rejects_malformed_pecos_dem_superset_metadata() { + let err = ParsedDem::from_str("pecos_tracked_pauli not-json").unwrap_err(); + assert!(matches!(err, DemParseError::InvalidPecosMetadata(_))); + } + + #[test] + fn test_parse_accepts_tracked_pauli_targets_without_metadata() { + let dem = ParsedDem::from_str("error(0.125) D1 L0 TP2").unwrap(); + + assert_eq!(dem.num_detectors, 2); + assert_eq!(dem.num_dem_outputs(), 1); + assert_eq!(dem.num_tracked_paulis(), 3); + assert_eq!(dem.mechanisms[0].components[0].detectors, vec![1]); + assert_eq!(dem.mechanisms[0].components[0].observables, vec![0]); + assert_eq!(dem.mechanisms[0].components[0].tracked_paulis, vec![2]); + assert_eq!(dem.mechanisms[0].effect_key().to_string(), "D1 L0 TP2"); + } + + #[test] + fn test_decomposed_tracked_pauli_targets_xor_by_parity() { + let cancels = ParsedDem::from_str("error(0.5) TP0 ^ TP0").unwrap(); + let (dets, obs, tracked_paulis) = cancels.mechanisms[0].combined_effect(); + assert!(dets.is_empty()); + assert!(obs.is_empty()); + assert!(tracked_paulis.is_empty()); + assert_eq!(cancels.mechanisms[0].effect_key().to_string(), "(empty)"); + + let leaves_detector = ParsedDem::from_str("error(0.5) D0 TP0 ^ TP0").unwrap(); + let (dets, obs, tracked_paulis) = leaves_detector.mechanisms[0].combined_effect(); + assert_eq!(dets, vec![0]); + assert!(obs.is_empty()); + assert!(tracked_paulis.is_empty()); + assert_eq!(leaves_detector.mechanisms[0].effect_key().to_string(), "D0"); + } + + #[test] + fn test_duplicate_tracked_pauli_targets_cancel_by_parity() { + let dem = ParsedDem::from_str("error(0.1) TP0 TP0").unwrap(); + assert_eq!(dem.mechanisms[0].components[0].tracked_paulis, vec![0, 0]); + + let (dets, obs, tracked_paulis) = dem.mechanisms[0].combined_effect(); + assert!(dets.is_empty()); + assert!(obs.is_empty()); + assert!(tracked_paulis.is_empty()); + assert_eq!(dem.mechanisms[0].effect_key().to_string(), "(empty)"); + } + + #[test] + fn test_parse_rejects_unknown_error_targets() { + let err = ParsedDem::from_str("error(0.125) D1 T0").unwrap_err(); + assert!(matches!(err, DemParseError::InvalidTarget(_))); + assert!(err.to_string().contains("Invalid DEM error target: T0")); + } + + #[test] + fn test_parse_rejects_malformed_tracked_pauli_targets() { + for target in ["TP", "TPx", "TP-1"] { + let err = ParsedDem::from_str(&format!("error(0.125) {target}")).unwrap_err(); + assert!( + matches!(err, DemParseError::InvalidTrackedPauliId(_)), + "{target} should be rejected as a malformed tracked-Pauli target" + ); + } + } + + #[test] + fn test_parsed_dem_sampler_projects_tracked_pauli_effects_but_keeps_metadata() { + let dem = ParsedDem::from_str("error(1.0) TP0").unwrap(); + let sampler = dem.to_dem_sampler(); + let mut rng = PecosRng::seed_from_u64(123); + + assert_eq!(dem.num_tracked_paulis(), 1); + assert_eq!(sampler.num_detectors(), 0); + assert_eq!(sampler.num_dem_outputs(), 0); + assert_eq!(sampler.num_tracked_paulis(), 1); + let (detectors, dem_outputs) = sampler.sample(&mut rng); + assert!(detectors.is_empty()); + assert!(dem_outputs.is_empty()); + let err = sampler.sample_tracked_pauli_flips(&mut rng).unwrap_err(); + assert_eq!(err.backend(), "DemSampler"); + assert_eq!(err.num_tracked_paulis(), 1); + } + + #[test] + fn test_parsed_dem_samplers_project_different_tracked_paulis_the_same() { + let dem1 = ParsedDem::from_str("error(1.0) D0 TP0").unwrap(); + let dem2 = ParsedDem::from_str("error(1.0) D0 TP1").unwrap(); + let sampler1 = dem1.to_dem_sampler(); + let sampler2 = dem2.to_dem_sampler(); + let mut rng1 = PecosRng::seed_from_u64(11); + let mut rng2 = PecosRng::seed_from_u64(11); + + assert_eq!(sampler1.num_detectors(), 1); + assert_eq!(sampler2.num_detectors(), 1); + assert_eq!(sampler1.num_dem_outputs(), 0); + assert_eq!(sampler2.num_dem_outputs(), 0); + assert_eq!(sampler1.num_tracked_paulis(), 1); + assert_eq!(sampler2.num_tracked_paulis(), 2); + assert_eq!(sampler1.sample(&mut rng1), sampler2.sample(&mut rng2)); + } + + #[test] + fn test_parse_rejects_unknown_pecos_dem_extension() { + let err = ParsedDem::from_str("pecos_old_extension {}").unwrap_err(); + assert!(matches!(err, DemParseError::InvalidPecosMetadata(_))); + assert!(err.to_string().contains("unsupported PECOS DEM extension")); + } + #[test] fn test_aggregate() { let dem_str = r" @@ -1050,6 +1439,41 @@ error(0.02) D1 D2 assert!(!result.details.only_in_dem2.is_empty()); } + #[test] + fn test_compare_exact_distinguishes_tracked_pauli_targets() { + let dem1 = ParsedDem::from_str("error(0.01) D0 TP0").unwrap(); + let dem2 = ParsedDem::from_str("error(0.01) D0 TP1").unwrap(); + + let result = compare_dems_exact(&dem1, &dem2, 1e-6); + assert!(!result.equivalent); + assert_eq!( + result + .details + .only_in_dem1 + .iter() + .map(ToString::to_string) + .collect::>(), + ["D0 TP0"] + ); + assert_eq!( + result + .details + .only_in_dem2 + .iter() + .map(ToString::to_string) + .collect::>(), + ["D0 TP1"] + ); + + let decomposed1 = ParsedDem::from_str("error(0.01) D0 TP0 ^ D1").unwrap(); + let decomposed2 = ParsedDem::from_str("error(0.01) D0 TP1 ^ D1").unwrap(); + let result = compare_dems_exact(&decomposed1, &decomposed2, 1e-6); + assert!( + !result.equivalent, + "exact PECOS DEM comparison must include tracked targets on decomposed components" + ); + } + #[test] fn test_statistical_comparison() { let dem_str = "error(0.5) D0"; @@ -1062,7 +1486,7 @@ error(0.02) D1 D2 #[test] fn test_decomposed_equivalent_to_simple() { - // In Stim's DEM format, these should be equivalent for sampling: + // In DEM syntax, these should be equivalent for sampling: // - error(0.1) D0 D1: D0 and D1 flip together with p=0.1 // - error(0.1) D0 ^ D1: D0 and D1 flip together with p=0.1 (^ is for decomposition tracking) let dem1 = ParsedDem::from_str("error(0.1) D0 D1").unwrap(); @@ -1093,9 +1517,10 @@ error(0.02) D1 D2 let dem = ParsedDem::from_str("error(0.5) D0 ^ D0").unwrap(); // The combined effect should be empty - let (dets, obs) = dem.mechanisms[0].combined_effect(); + let (dets, obs, tracked_paulis) = dem.mechanisms[0].combined_effect(); assert!(dets.is_empty()); assert!(obs.is_empty()); + assert!(tracked_paulis.is_empty()); // Sample and verify D0 never fires let mut rng = PecosRng::seed_from_u64(42); 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 201bab51b..85176ec4c 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 @@ -12,36 +12,10 @@ //! Measurement Noise Model (MNM) builder implementation. //! -//! This module builds a MNM from a fault influence map. Unlike the DEM builder -//! which maps to detector effects, the MNM maps directly to measurement effects -//! for fast approximate sampling. -//! -//! # Usage -//! -//! ``` -//! use pecos_qec::fault_tolerance::DagFaultAnalyzer; -//! use pecos_qec::fault_tolerance::dem_builder::MemBuilder; -//! use pecos_quantum::DagCircuit; -//! use rand::SeedableRng; -//! use rand::rngs::SmallRng; -//! -//! let mut dag = DagCircuit::new(); -//! dag.pz(&[2]); -//! dag.cx(&[(0, 2)]); -//! dag.cx(&[(1, 2)]); -//! dag.mz(&[2]); -//! -//! let analyzer = DagFaultAnalyzer::new(&dag); -//! let influence_map = analyzer.build_influence_map(); -//! -//! let mnm = MemBuilder::new(&influence_map) -//! .with_noise(0.01, 0.01, 0.01, 0.01) -//! .build(); -//! -//! // Sample measurement outcomes -//! let mut rng = SmallRng::seed_from_u64(42); -//! let outcomes = mnm.sample(&mut rng); -//! ``` +//! This module builds a measurement-level noise model from a fault influence +//! map. Unlike a DEM, which maps faults to detector and observable effects, +//! the MNM maps faults directly to raw measurement flips for fast approximate +//! sampling. use super::types::{MeasurementMechanism, MeasurementNoiseModel, NoiseConfig}; use crate::fault_tolerance::propagator::{DagFaultInfluenceMap, Pauli}; @@ -49,28 +23,12 @@ use pecos_core::gate_type::GateType; use smallvec::SmallVec; /// Builder for Measurement Noise Models (MNMs). -/// -/// Constructs a MNM from a fault influence map. The MNM aggregates fault locations -/// by their measurement effects (which measurements flip), enabling fast approximate -/// sampling. -/// -/// # Comparison with DEM -/// -/// | Aspect | DEM | MNM | -/// |--------|-----|-----| -/// | Maps to | Detectors | Measurements | -/// | Use case | Decoding | Sampling | -/// | Aggregates by | Detector signature | Measurement signature | -/// | Output | Stim-compatible DEM | Raw measurement outcomes | pub struct MemBuilder<'a> { /// Reference to the fault influence map. influence_map: &'a DagFaultInfluenceMap, /// Noise configuration. noise: NoiseConfig, - /// Measurement order from the original circuit (e.g., `TickCircuit`). - /// This is a list of qubits in the order they were measured. - /// `measurement_order[tc_idx] = qubit` means the tc_idx-th measurement - /// in the `TickCircuit` was on this qubit. + /// Optional measurement order from the original circuit. measurement_order: Option>, } @@ -85,114 +43,69 @@ impl<'a> MemBuilder<'a> { } } - /// Sets the noise configuration. + /// Sets the scalar noise configuration. #[must_use] - pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_init: f64) -> Self { - self.noise = NoiseConfig::new(p1, p2, p_meas, p_init); + pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { + self.noise = NoiseConfig::new(p1, p2, p_meas, p_prep); self } - /// Sets the measurement order from the original circuit (e.g., `TickCircuit`). - /// - /// This is needed when detector definitions use `TickCircuit` measurement indices - /// but the influence map uses a different ordering based on DAG topology. - /// - /// # Arguments - /// - /// * `order` - List of qubit indices in measurement execution order. - /// `order[tc_idx] = qubit` means the tc_idx-th measurement in the `TickCircuit` - /// was on this qubit. + /// Sets the full noise configuration. #[must_use] - pub fn with_measurement_order(mut self, order: Vec) -> Self { - self.measurement_order = Some(order); + pub fn with_noise_config(mut self, noise: NoiseConfig) -> Self { + self.noise = noise; self } - /// Computes the mapping from influence map measurement indices to `TickCircuit` indices. - /// - /// Returns a vector where `result[im_idx] = tc_idx`, mapping each influence map - /// measurement to its corresponding `TickCircuit` measurement. - fn compute_im_to_tc_mapping(&self, tc_order: &[usize]) -> Vec { - let im_measurements = &self.influence_map.measurements; - let num_measurements = im_measurements.len(); - - // Build map: qubit -> list of TC indices where that qubit is measured - let mut tc_indices_by_qubit: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for (tc_idx, &qubit) in tc_order.iter().enumerate() { - tc_indices_by_qubit.entry(qubit).or_default().push(tc_idx); - } - - // Build map: qubit -> list of IM indices where that qubit is measured - let mut im_indices_by_qubit: std::collections::BTreeMap> = - std::collections::BTreeMap::new(); - for (im_idx, &(_node, qubit, _basis)) in im_measurements.iter().enumerate() { - im_indices_by_qubit.entry(qubit).or_default().push(im_idx); - } - - // Build the mapping: for each qubit, match IM indices to TC indices in order - let mut im_to_tc = vec![0; num_measurements]; - for (qubit, im_indices) in &im_indices_by_qubit { - if let Some(tc_indices) = tc_indices_by_qubit.get(qubit) { - // Match in order - the i-th IM measurement of this qubit maps to - // the i-th TC measurement of this qubit - for (i, &im_idx) in im_indices.iter().enumerate() { - if i < tc_indices.len() { - im_to_tc[im_idx] = tc_indices[i]; - } - } - } - } - - im_to_tc + /// Sets the measurement order from the original circuit. + #[must_use] + pub fn with_measurement_order(mut self, order: Vec) -> Self { + self.measurement_order = Some(order); + self } /// Builds the Measurement Noise Model. - /// - /// This aggregates all fault locations by their measurement effects. - /// Locations that produce the same measurement signature have their - /// probabilities combined using the independent error formula. #[must_use] pub fn build(&self) -> MeasurementNoiseModel { let num_measurements = self.influence_map.measurements.len(); let mut mem = MeasurementNoiseModel::new(num_measurements); - // Compute im_to_tc mapping if measurement order is provided if let Some(ref tc_order) = self.measurement_order { - let im_to_tc = self.compute_im_to_tc_mapping(tc_order); - mem.set_measurement_order(im_to_tc); + mem.set_measurement_order(self.compute_im_to_tc_mapping(tc_order)); } let locations = &self.influence_map.locations; - - // Group CX locations by node for two-qubit gate processing - let mut cx_groups: std::collections::BTreeMap> = + let mut two_qubit_groups: std::collections::BTreeMap> = std::collections::BTreeMap::new(); for (loc_idx, loc) in locations.iter().enumerate() { match loc.gate_type { - GateType::PZ | GateType::QAlloc - // Prep errors: only "after" locations - if self.noise.p_init > 0.0 && !loc.before => { - self.process_prep_fault(loc_idx, &mut mem); - } - GateType::MZ | GateType::MeasureFree - // Measurement errors: only "before" locations - if self.noise.p_meas > 0.0 && loc.before => { - self.process_meas_fault(loc_idx, &mut mem); - } + GateType::PZ | GateType::QAlloc if self.noise.p_prep > 0.0 && !loc.before => { + self.process_single_pauli_fault(loc_idx, Pauli::X, self.noise.p_prep, &mut mem); + } + GateType::MZ | GateType::MeasureFree if self.noise.p_meas > 0.0 && loc.before => { + self.process_single_pauli_fault(loc_idx, Pauli::X, self.noise.p_meas, &mut mem); + } 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 - // Two-qubit gate errors: only "after" locations - if !loc.before => { - cx_groups.entry(loc.node).or_default().push(loc_idx); - } + if !loc.before => + { + two_qubit_groups.entry(loc.node).or_default().push(loc_idx); + } GateType::H + | GateType::F + | GateType::Fdg | GateType::SZ | GateType::SZdg | GateType::SX @@ -209,19 +122,38 @@ impl<'a> MemBuilder<'a> { | GateType::RZ | GateType::U | GateType::R1XY - // Single-qubit gate errors: only "after" locations - if self.noise.p1 > 0.0 && !loc.before => { + if self.noise.p1 > 0.0 && !loc.before => + { + self.process_single_qubit_fault(loc_idx, &mut mem); + } + 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 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); + } + if probs.py > 0.0 { + self.process_single_pauli_fault(loc_idx, Pauli::Y, probs.py, &mut mem); + } + if probs.pz > 0.0 { + self.process_single_pauli_fault(loc_idx, Pauli::Z, probs.pz, &mut mem); + } + } else if self.noise.p1 > 0.0 { self.process_single_qubit_fault(loc_idx, &mut mem); } + } _ => {} } } - // Process two-qubit gates if self.noise.p2 > 0.0 { - for (_, loc_indices) in cx_groups { - if loc_indices.len() == 2 { - self.process_two_qubit_fault(loc_indices[0], loc_indices[1], &mut mem); + for loc_indices in two_qubit_groups.values() { + for pair in loc_indices.chunks(2) { + if pair.len() == 2 { + self.process_two_qubit_fault(pair[0], pair[1], &mut mem); + } } } } @@ -229,45 +161,57 @@ impl<'a> MemBuilder<'a> { mem } - /// Processes a prep/initialization fault location. - fn process_prep_fault(&self, loc_idx: usize, mem: &mut MeasurementNoiseModel) { - // For Z-basis prep, X error matters - let mechanism = self.compute_mechanism(loc_idx, Pauli::X); - if !mechanism.is_empty() { - mem.add_mechanism(mechanism, self.noise.p_init); + fn compute_im_to_tc_mapping(&self, tc_order: &[usize]) -> Vec { + let im_measurements = &self.influence_map.measurements; + let mut tc_indices_by_qubit: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for (tc_idx, &qubit) in tc_order.iter().enumerate() { + tc_indices_by_qubit.entry(qubit).or_default().push(tc_idx); } + + let mut im_indices_by_qubit: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + for (im_idx, &(_node, qubit, _basis)) in im_measurements.iter().enumerate() { + im_indices_by_qubit.entry(qubit).or_default().push(im_idx); + } + + let mut im_to_tc = vec![0; im_measurements.len()]; + for (qubit, im_indices) in &im_indices_by_qubit { + if let Some(tc_indices) = tc_indices_by_qubit.get(qubit) { + for (i, &im_idx) in im_indices.iter().enumerate() { + if let Some(&tc_idx) = tc_indices.get(i) { + im_to_tc[im_idx] = tc_idx; + } + } + } + } + im_to_tc } - /// Processes a measurement fault location. - fn process_meas_fault(&self, loc_idx: usize, mem: &mut MeasurementNoiseModel) { - // Measurement error is a bit flip (X error) - let mechanism = self.compute_mechanism(loc_idx, Pauli::X); + fn process_single_pauli_fault( + &self, + loc_idx: usize, + pauli: Pauli, + prob: f64, + mem: &mut MeasurementNoiseModel, + ) { + let mechanism = self.compute_mechanism(loc_idx, pauli); if !mechanism.is_empty() { - mem.add_mechanism(mechanism, self.noise.p_meas); + mem.add_mechanism(mechanism, prob); } } - /// Processes a single-qubit gate fault location. fn process_single_qubit_fault(&self, loc_idx: usize, mem: &mut MeasurementNoiseModel) { - // Depolarizing: each of X, Y, Z with probability p1/3 let prob = self.noise.p1 / 3.0; - for pauli in [Pauli::X, Pauli::Y, Pauli::Z] { - let mechanism = self.compute_mechanism(loc_idx, pauli); - if !mechanism.is_empty() { - mem.add_mechanism(mechanism, prob); - } + self.process_single_pauli_fault(loc_idx, pauli, prob, mem); } } - /// Processes a two-qubit gate fault (CX or CZ). fn process_two_qubit_fault(&self, loc1: usize, loc2: usize, mem: &mut MeasurementNoiseModel) { - // Two-qubit depolarizing: 15 non-identity Pauli combinations with p2/15 each let prob = self.noise.p2 / 15.0; - let paulis = [Pauli::I, Pauli::X, Pauli::Y, Pauli::Z]; - // Cache single-qubit effects for each Pauli on each qubit let mut effects1: [Option; 4] = [None, None, None, None]; let mut effects2: [Option; 4] = [None, None, None, None]; @@ -276,24 +220,21 @@ impl<'a> MemBuilder<'a> { effects2[p.as_u8() as usize] = Some(self.compute_mechanism(loc2, p)); } - // Process all 15 non-trivial Pauli combinations for &p1 in &paulis { for &p2 in &paulis { if p1 == Pauli::I && p2 == Pauli::I { - continue; // Skip II + continue; } let mechanism = if p1 == Pauli::I { - // IX, IY, IZ effects2[p2.as_u8() as usize].clone().unwrap_or_default() } else if p2 == Pauli::I { - // XI, YI, ZI effects1[p1.as_u8() as usize].clone().unwrap_or_default() } else { - // Correlated: XOR the measurement effects - let e1 = effects1[p1.as_u8() as usize].as_ref(); - let e2 = effects2[p2.as_u8() as usize].as_ref(); - xor_measurement_mechanisms(e1, e2) + xor_measurement_mechanisms( + effects1[p1.as_u8() as usize].as_ref(), + effects2[p2.as_u8() as usize].as_ref(), + ) }; if !mechanism.is_empty() { @@ -303,9 +244,7 @@ impl<'a> MemBuilder<'a> { } } - /// Computes the measurement mechanism for a fault at the given location and Pauli type. fn compute_mechanism(&self, loc_idx: usize, pauli: Pauli) -> MeasurementMechanism { - // Get the measurement indices that this fault flips let measurements = self .influence_map .get_detector_indices(loc_idx, pauli.as_u8()); @@ -317,7 +256,6 @@ impl<'a> MemBuilder<'a> { } } -/// XORs two measurement mechanisms (symmetric difference). fn xor_measurement_mechanisms( a: Option<&MeasurementMechanism>, b: Option<&MeasurementMechanism>, @@ -339,7 +277,6 @@ fn xor_measurement_mechanisms( j += 1; } std::cmp::Ordering::Equal => { - // Same element in both - XOR cancels i += 1; j += 1; } @@ -348,124 +285,9 @@ fn xor_measurement_mechanisms( result.extend_from_slice(&m1.measurements[i..]); result.extend_from_slice(&m2.measurements[j..]); - MeasurementMechanism::from_sorted(result) } (Some(m), None) | (None, Some(m)) => m.clone(), (None, None) => MeasurementMechanism::new(), } } - -#[cfg(test)] -mod tests { - use super::*; - use crate::fault_tolerance::propagator::DagFaultAnalyzer; - use pecos_quantum::DagCircuit; - - #[test] - fn test_mem_builder_simple() { - // Simple circuit: prep, cx, measure - let mut dag = DagCircuit::new(); - dag.pz(&[2]); // Prep ancilla - dag.cx(&[(0, 2)]); // CNOT data -> ancilla - dag.cx(&[(1, 2)]); // CNOT data -> ancilla - dag.mz(&[2]); // Measure ancilla - - let analyzer = DagFaultAnalyzer::new(&dag); - let influence_map = analyzer.build_influence_map(); - - let mem = MemBuilder::new(&influence_map) - .with_noise(0.01, 0.01, 0.01, 0.01) - .build(); - - // Should have some mechanisms - assert!(mem.num_mechanisms() > 0); - assert_eq!(mem.num_measurements, 1); - } - - #[test] - fn test_mem_builder_aggregation() { - // Circuit where multiple locations produce the same measurement effect - let mut dag = DagCircuit::new(); - dag.pz(&[2]); - dag.cx(&[(0, 2)]); - dag.cx(&[(1, 2)]); - dag.mz(&[2]); - - let analyzer = DagFaultAnalyzer::new(&dag); - let influence_map = analyzer.build_influence_map(); - - let mem = MemBuilder::new(&influence_map) - .with_noise(0.01, 0.01, 0.01, 0.01) - .build(); - - // Count how many mechanisms flip measurement 0 - let single_meas_mechanisms: Vec<_> = mem - .iter() - .filter(|(m, _)| m.measurements.as_slice() == [0]) - .collect(); - - // Should have aggregated multiple sources into one mechanism - // (prep X error + measurement X error both flip measurement 0) - assert!( - single_meas_mechanisms.len() == 1, - "Expected aggregation of mechanisms with same effect" - ); - - // Combined probability should be > individual probability - let combined_prob = single_meas_mechanisms[0].1; - assert!( - *combined_prob > 0.01, - "Combined probability should be greater than single source" - ); - } - - #[test] - fn test_mem_sampling() { - use rand::SeedableRng; - use rand::rngs::SmallRng; - - let mut dag = DagCircuit::new(); - dag.pz(&[2]); - dag.cx(&[(0, 2)]); - dag.mz(&[2]); - - let analyzer = DagFaultAnalyzer::new(&dag); - let influence_map = analyzer.build_influence_map(); - - let mem = MemBuilder::new(&influence_map) - .with_noise(0.1, 0.1, 0.1, 0.1) // High error rate for testing - .build(); - - let mut rng = SmallRng::seed_from_u64(42); - - // Sample many shots and count flips - let num_shots = 10000; - let mut flip_count = 0; - - for _ in 0..num_shots { - let outcomes = mem.sample(&mut rng); - if outcomes.first().copied().unwrap_or(false) { - flip_count += 1; - } - } - - // Should have some flips (not 0) and some non-flips (not all) - assert!(flip_count > 0, "Should have some measurement flips"); - assert!( - flip_count < num_shots, - "Should not have all measurements flipped" - ); - } - - #[test] - fn test_xor_measurement_mechanisms() { - let m1 = MeasurementMechanism::from_unsorted([0, 1, 2]); - let m2 = MeasurementMechanism::from_unsorted([1, 2, 3]); - - let result = xor_measurement_mechanisms(Some(&m1), Some(&m2)); - - // {0, 1, 2} XOR {1, 2, 3} = {0, 3} - assert_eq!(result.measurements.as_slice(), &[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 new file mode 100644 index 000000000..d564fbf49 --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/sampler.rs @@ -0,0 +1,2137 @@ +// 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. + +//! Unified sampler for noisy QEC measurement outcomes. +//! +//! This sampler unifies the DEM (detector-level) and MNM (measurement-level) +//! sampling paths into a single type. Internally it uses [`DemSampler`]'s +//! efficient geometric-skip engine for fault mechanism sampling, then applies +//! an optional detector basis change and non-deterministic coin flips depending +//! on the requested output mode. +//! +//! # Coordinate systems +//! +//! Deterministic measurements form a basis in `Z_2`. User-defined detectors are +//! linear combinations (XOR chains) of these measurements — a change of basis. +//! The sampler always builds its mechanism table in raw measurement coordinates, +//! then applies the basis change at build time if detector definitions are +//! provided. +//! +//! # Construction modes +//! +//! - **Raw measurements**: each deterministic measurement is its own "detector." +//! Output includes coin flips for non-deterministic measurements. +//! - **Auto-detected detectors**: uses the influence builder's detector +//! definitions (round-to-round XOR of stabilizer measurements). +//! - **User-defined detectors**: arbitrary XOR combinations of measurements, +//! validated at build time. + +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_num::z2_linalg::z2_rank_from_records; +use pecos_random::RngProbabilityExt; +use rand_core::Rng; + +/// Errors from detector definition validation. +#[derive(Debug, Clone)] +pub enum DetectorValidationError { + /// A detector definition references a non-deterministic measurement. + NonDeterministicReference { + detector_id: usize, + measurement_idx: usize, + }, + /// Detector definitions are not linearly independent over `Z_2`. + LinearlyDependent { rank: usize, num_detectors: usize }, + /// Circuit contains gates not supported by the symbolic determinism analysis. + /// Raw measurement mode requires all gates to be in the supported Clifford + /// subset (`H`, `X`, `Y`, `Z`, `SZ`, `SZdg`, `CX`, `CZ`, `SWAP`, `MZ`, `PZ`, `I`). + UnsupportedGateForDeterminismAnalysis { gate_type: String }, +} + +impl std::fmt::Display for DetectorValidationError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Self::NonDeterministicReference { + detector_id, + measurement_idx, + } => { + write!( + f, + "Detector {detector_id} references non-deterministic measurement {measurement_idx}. \ + Detectors should only XOR deterministic measurements." + ) + } + Self::LinearlyDependent { + rank, + num_detectors, + } => { + write!( + f, + "Detector definitions are not linearly independent: \ + rank {rank} < {num_detectors} detectors. \ + Some detectors are redundant (XOR of other detectors)." + ) + } + Self::UnsupportedGateForDeterminismAnalysis { gate_type } => { + write!( + f, + "Circuit contains gate type '{gate_type}' which is not supported by \ + raw measurement determinism analysis. Supported Clifford gates: \ + H, X, Y, Z, SZ, SZdg, CX, CZ, SWAP, MZ, PZ/QAlloc, I/Idle." + ) + } + } + } +} + +impl std::error::Error for DetectorValidationError {} + +/// Error returned when a sampler backend is asked to directly evaluate tracked +/// Paulis it only preserves as metadata. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct TrackedPauliSamplingError { + backend: &'static str, + num_tracked_paulis: usize, +} + +impl TrackedPauliSamplingError { + fn new(backend: &'static str, num_tracked_paulis: usize) -> Self { + Self { + backend, + num_tracked_paulis, + } + } + + /// Backend that rejected direct tracked-Pauli sampling. + #[must_use] + pub fn backend(&self) -> &'static str { + self.backend + } + + /// Number of tracked Paulis carried as metadata by that backend. + #[must_use] + pub fn num_tracked_paulis(&self) -> usize { + self.num_tracked_paulis + } +} + +impl std::fmt::Display for TrackedPauliSamplingError { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + write!( + f, + "{} cannot directly sample tracked Pauli flips for {} tracked Pauli(s). \ + This backend samples decoder-facing detectors and observables only; tracked \ + Paulis are preserved as PECOS metadata and fault effects.", + self.backend, self.num_tracked_paulis + ) + } +} + +impl std::error::Error for TrackedPauliSamplingError {} + +/// Output mode for the unified sampler. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum OutputMode { + /// Output raw measurement values (deterministic flips + non-det coin flips). + RawMeasurements, + /// Output detector events (XOR of measurement groups) + observable flips. + DetectorEvents, +} + +/// Unified sampler that handles both measurement-level and detector-level output. +/// +/// Uses [`DemSampler`]'s geometric-skip engine internally. The mechanism table +/// is always in the output coordinate system (raw measurements or user detectors), +/// determined at build time. +/// Result of dual-mode sampling: both raw measurements and detector events. +#[derive(Debug, Clone)] +pub struct DualSampleResult { + /// Raw measurement values (deterministic flips + non-det coin flips). + pub raw_measurements: Vec, + /// Detector events (XOR of measurement groups). + pub detector_events: Vec, + /// Standard DEM `L` observable output flips. + pub dem_output_flips: Vec, +} + +/// Labels for sampler output channels. +#[derive(Debug, Clone, Default)] +pub struct SamplerLabels { + /// Labels for output channels (raw measurements or detectors, depending on mode). + pub outputs: Vec>, + /// Labels for standard DEM `L` observable outputs. + /// Indices match `per_dem_output` in `SamplingStatistics`. + pub dem_output_labels: Vec>, + /// Full PECOS metadata for standard DEM `L` observables. + pub dem_outputs: Vec>, + /// Labels for PECOS tracked Paulis. + pub tracked_pauli_labels: Vec>, + /// Full PECOS metadata for tracked Paulis in their own ID space. + pub tracked_paulis: Vec>, + /// Labels for dual-output detector channels. + pub dual_detectors: Vec>, +} + +fn dem_outputs_by_id(targets: &[DemOutput], num_dem_outputs: usize) -> Vec> { + let mut by_id = vec![None; num_dem_outputs]; + for target in targets { + let idx = target.id as usize; + if idx < by_id.len() { + by_id[idx] = Some(target.clone()); + } + } + by_id +} + +fn labels_from_dem_outputs(targets: &[Option]) -> Vec> { + targets + .iter() + .map(|target| target.as_ref().and_then(|target| target.label.clone())) + .collect() +} + +fn dem_outputs_from_influence_map( + influence_map: &DagFaultInfluenceMap, + num_dem_outputs: usize, +) -> Vec> { + let mut targets = vec![None; num_dem_outputs]; + for (internal_id, metadata) in influence_map.dem_output_metadata.iter().enumerate() { + if metadata.kind == DemOutputKind::Observable { + #[allow(clippy::cast_possible_truncation)] // DEM output count fits in u32 + if let Some(dem_output_id) = + influence_map.observable_id_for_internal_dem_output(internal_id as u32) + { + let idx = dem_output_id as usize; + if idx < targets.len() { + targets[idx] = Some(DemOutput::from_metadata(dem_output_id, metadata)); + } + } + } + } + targets +} + +fn tracked_paulis_from_influence_map( + influence_map: &DagFaultInfluenceMap, +) -> Vec> { + let mut tracked_paulis = Vec::new(); + for metadata in &influence_map.dem_output_metadata { + if metadata.kind == DemOutputKind::TrackedPauli { + #[allow(clippy::cast_possible_truncation)] // tracked-Pauli count fits in u32 + let id = tracked_paulis.len() as u32; + tracked_paulis.push(Some(DemOutput::from_metadata(id, metadata))); + } + } + tracked_paulis +} + +fn dem_outputs_from_records( + influence_map: &DagFaultInfluenceMap, + observable_records: &[Vec], + num_dem_outputs: usize, +) -> Vec> { + let mut targets = dem_outputs_from_influence_map(influence_map, num_dem_outputs); + + for (record_id, records) in observable_records.iter().enumerate() { + let dem_output_id = record_id; + if dem_output_id < targets.len() { + if let Some(target) = &mut targets[dem_output_id] { + if target.records.is_empty() { + target.records = DemOutput::new(target.id) + .with_records(records.iter().copied()) + .records; + } + target.kind.get_or_insert(DemOutputKind::Observable); + } else { + #[allow(clippy::cast_possible_truncation)] // DEM output count fits in u32 + { + targets[dem_output_id] = Some( + DemOutput::new(dem_output_id as u32).with_records(records.iter().copied()), + ); + } + } + } + } + + targets +} + +fn merge_dem_output_metadata( + mut labels: SamplerLabels, + targets: Vec>, + tracked_paulis: Vec>, +) -> SamplerLabels { + if labels.dem_outputs.len() < targets.len() { + labels.dem_outputs.resize(targets.len(), None); + } + for (idx, target) in targets.into_iter().enumerate() { + if labels.dem_outputs[idx].is_none() { + labels.dem_outputs[idx] = target; + } + } + + let target_labels = labels_from_dem_outputs(&labels.dem_outputs); + if labels.dem_output_labels.len() < target_labels.len() { + labels.dem_output_labels.resize(target_labels.len(), None); + } + for (idx, label) in target_labels.into_iter().enumerate() { + if labels.dem_output_labels[idx].is_none() { + labels.dem_output_labels[idx] = label; + } + } + + if labels.tracked_paulis.len() < tracked_paulis.len() { + labels.tracked_paulis.resize(tracked_paulis.len(), None); + } + for (idx, tracked_pauli) in tracked_paulis.into_iter().enumerate() { + if labels.tracked_paulis[idx].is_none() { + labels.tracked_paulis[idx] = tracked_pauli; + } + } + + let tracked_pauli_labels = labels_from_dem_outputs(&labels.tracked_paulis); + if labels.tracked_pauli_labels.len() < tracked_pauli_labels.len() { + labels + .tracked_pauli_labels + .resize(tracked_pauli_labels.len(), None); + } + for (idx, label) in tracked_pauli_labels.into_iter().enumerate() { + if labels.tracked_pauli_labels[idx].is_none() { + labels.tracked_pauli_labels[idx] = label; + } + } + + labels +} + +#[derive(Debug, Clone)] +pub struct DemSampler { + /// The efficient sampling engine (mechanism table in raw measurement coords). + inner: SamplingEngine, + + /// Which output indices are non-deterministic (true = coin flip, not from mechanisms). + /// Length = `num_outputs` (full measurement space in raw mode). + non_det_mask: Vec, + + /// Deterministic measurement dependencies for raw mode. + /// `measurement_deps[i] = Some((deps, flip))` means measurement i is determined by + /// XOR(measurements[j] for j in deps) XOR flip. None = non-det (coin flip) or fault-only. + /// Used to propagate non-det coin flips through the dependency chain. + measurement_deps: Vec, bool)>>, + + /// Detector definitions for dual-output mode. + /// Each entry is a list of absolute measurement indices to XOR. + detector_records_abs: Vec>, + + /// Output mode this sampler was built for. + mode: OutputMode, + + /// Total number of output channels (measurements or detectors). + num_outputs: usize, + + /// Total number of outputs in the DEM `L` namespace. + num_dem_outputs: usize, + + /// Optional labels for output channels. + labels: SamplerLabels, + + /// Remap table for raw mode: engine index → absolute measurement index. + /// When set, the engine operates in compressed coordinates (only fault-reachable + /// measurements) and the output is expanded to the full measurement space. + /// None when engine coordinates == output coordinates (no expansion needed). + raw_remap: Option>, +} + +impl DemSampler { + /// Build a `DemSampler` directly from an annotated circuit and noise config. + /// + /// This is the simplest way to go from circuit to sampler. It: + /// 1. Builds a raw-measurement influence map via `DagFaultAnalyzer` + /// 2. Extracts detector, observable, and Pauli check annotations from the circuit + /// 3. Applies the noise configuration + /// 4. Returns a ready-to-sample `DemSampler` + /// + /// For circuits with Pauli check annotations, this also builds + /// the influence map with those checks via `InfluenceBuilder`. + /// + /// # Errors + /// + /// Returns [`DetectorValidationError`] if any detector references a + /// non-deterministic measurement or the detectors are linearly dependent. + /// + /// # Example + /// + /// ``` + /// use rand::SeedableRng; + /// use rand::rngs::StdRng; + /// + /// use pecos_qec::fault_tolerance::dem_builder::{DemSampler, NoiseConfig}; + /// use pecos_quantum::DagCircuit; + /// + /// let dag = DagCircuit::new(); + /// let noise = NoiseConfig::uniform(0.01); + /// let sampler = DemSampler::from_circuit(&dag, &noise).unwrap(); + /// + /// let mut rng = StdRng::seed_from_u64(123); + /// let (det, obs) = sampler.sample(&mut rng); + /// assert!(det.is_empty()); + /// assert!(obs.is_empty()); + /// ``` + /// Build a sampler from a `TickCircuit` and noise parameters. + /// + /// Converts to `DagCircuit` internally. Returns detector-mode sampler. + pub fn from_tick_circuit( + circuit: &pecos_quantum::TickCircuit, + noise: &super::types::NoiseConfig, + ) -> Result { + let dag = pecos_quantum::DagCircuit::from(circuit); + Self::from_circuit(&dag, noise) + } + + /// Build a sampler from a `DagCircuit` and noise parameters. + /// + /// # Errors + /// + /// Returns [`DetectorValidationError`] when detector metadata is invalid + /// for the circuit's measurement record. + pub fn from_circuit( + circuit: &pecos_quantum::DagCircuit, + noise: &super::types::NoiseConfig, + ) -> Result { + // Build the DetectorErrorModel via DemBuilder (single code path for + // DEM computation), then convert to sampler. + use super::builder::DemBuilder; + use crate::fault_tolerance::influence_builder::InfluenceBuilder; + use crate::fault_tolerance::propagator::DagFaultAnalyzer; + + let mut influence_map = DagFaultAnalyzer::new(circuit).build_influence_map(); + let annotation_map = InfluenceBuilder::new(circuit) + .with_circuit_annotations(circuit) + .build(); + influence_map.merge_dem_outputs_from(&annotation_map); + + // Extract metadata before building (avoids ownership issues with builder methods) + let det_json = { + use pecos_num::graph::Attribute; + circuit.get_attr("detectors").and_then(|a| { + if let Attribute::String(s) = a { + Some(s.clone()) + } else { + None + } + }) + }; + let observables_json = { + use pecos_num::graph::Attribute; + circuit.get_attr("observables").and_then(|a| { + if let Attribute::String(s) = a { + Some(s.clone()) + } else { + None + } + }) + }; + let num_meas = { + use pecos_num::graph::Attribute; + circuit.get_attr("num_measurements").and_then(|a| { + if let Attribute::String(s) = a { + s.parse::().ok() + } else { + None + } + }) + }; + + // Build DemBuilder, applying detector/DEM-output JSON if available. + // with_detectors_json/with_observables_json consume self, so we + // chain them carefully. + let builder = DemBuilder::new(&influence_map).with_noise_config(noise.clone()); + + let builder = if let Some(ref dj) = det_json { + builder.with_detectors_json(dj).unwrap_or_else(|_| { + DemBuilder::new(&influence_map).with_noise_config(noise.clone()) + }) + } else { + builder + }; + + let builder = if let Some(ref oj) = observables_json { + builder.with_observables_json(oj).unwrap_or_else(|_| { + DemBuilder::new(&influence_map).with_noise_config(noise.clone()) + }) + } else { + builder + }; + + let builder = if let Some(n) = num_meas { + builder.with_num_measurements(n) + } else { + builder + }; + + let dem = builder.build(); + Ok(Self::from_detector_error_model(&dem)) + } + + /// Wrap a raw [`SamplingEngine`] as a detector-mode `DemSampler`. + /// + /// Used when the engine was constructed externally (e.g., from + /// [`ParsedDem::to_dem_sampler`]). + #[must_use] + /// Create a `DemSampler` from a pre-built `SamplingEngine`. + pub fn from_engine(engine: SamplingEngine) -> Self { + let num_outputs = engine.num_detectors(); + let num_dem_outputs = engine.num_dem_outputs(); + Self { + inner: engine, + non_det_mask: Vec::new(), + detector_records_abs: Vec::new(), + mode: OutputMode::DetectorEvents, + num_outputs, + num_dem_outputs, + labels: SamplerLabels::default(), + raw_remap: None, + measurement_deps: Vec::new(), + } + } + + /// Build a detector-event sampler from a [`DetectorErrorModel`], preserving + /// PECOS metadata for observables and tracked Paulis. + #[must_use] + pub fn from_detector_error_model(dem: &super::types::DetectorErrorModel) -> Self { + let (mechanisms, _coords) = dem.to_mechanisms(); + let engine = + SamplingEngine::from_mechanisms(mechanisms, dem.num_detectors(), dem.num_dem_outputs()); + let mut sampler = Self::from_engine(engine); + sampler.labels.dem_outputs = dem_outputs_by_id(dem.dem_outputs(), dem.num_dem_outputs()); + sampler.labels.dem_output_labels = labels_from_dem_outputs(&sampler.labels.dem_outputs); + sampler.labels.tracked_paulis = + dem_outputs_by_id(dem.tracked_paulis(), dem.num_tracked_paulis()); + sampler.labels.tracked_pauli_labels = + labels_from_dem_outputs(&sampler.labels.tracked_paulis); + sampler + } + + /// Attach observable and tracked-Pauli metadata to an existing sampler. + /// + /// This is useful for parser paths where the sampling engine projects to + /// detector/observable columns but the original PECOS DEM still declared + /// tracked Paulis in a separate ID space. + #[must_use] + pub fn with_dem_output_metadata( + mut self, + dem_outputs: Vec>, + tracked_paulis: Vec>, + ) -> Self { + self.labels.dem_outputs = dem_outputs; + self.labels.dem_output_labels = labels_from_dem_outputs(&self.labels.dem_outputs); + self.labels.tracked_paulis = tracked_paulis; + self.labels.tracked_pauli_labels = labels_from_dem_outputs(&self.labels.tracked_paulis); + self + } + + /// Reconstruct a detector error model from the compiled mechanism table. + /// + /// The returned model contains mechanism probabilities and effects. Higher + /// level wrappers that own detector / observable definitions should add + /// those declarations to preserve metadata in serialized text. + #[must_use] + pub fn to_detector_error_model(&self) -> super::types::DetectorErrorModel { + self.inner.to_detector_error_model() + } + + /// Create a `DemSampler` directly from an influence map with per-location + /// probabilities (raw measurement mode). + #[must_use] + pub fn from_influence_map( + influence_map: &DagFaultInfluenceMap, + per_location_probs: &[f64], + ) -> Self { + let default_noise = super::NoiseConfig::default(); + let inner = + SamplingEngine::from_influence_map(influence_map, per_location_probs, &default_noise); + let num_outputs = inner.num_detectors(); + let num_dem_outputs = inner.num_dem_outputs(); + let mut labels = SamplerLabels::default(); + labels.dem_outputs = dem_outputs_from_influence_map(influence_map, num_dem_outputs); + labels.dem_output_labels = labels_from_dem_outputs(&labels.dem_outputs); + labels.tracked_paulis = tracked_paulis_from_influence_map(influence_map); + labels.tracked_pauli_labels = labels_from_dem_outputs(&labels.tracked_paulis); + Self { + inner, + non_det_mask: Vec::new(), + detector_records_abs: Vec::new(), + mode: OutputMode::RawMeasurements, + num_outputs, + num_dem_outputs, + labels, + raw_remap: None, + measurement_deps: Vec::new(), + } + } + + /// Number of output channels (measurements in raw mode, detectors in detector mode). + #[must_use] + pub fn num_outputs(&self) -> usize { + self.num_outputs + } + + /// Number of detectors (alias for [`num_outputs`] in detector mode). + #[must_use] + pub fn num_detectors(&self) -> usize { + self.num_outputs + } + + /// Number of observables. + #[must_use] + pub fn num_observables(&self) -> usize { + self.num_dem_outputs + } + + /// Number of DEM `L` output channels. + #[must_use] + pub fn num_dem_outputs(&self) -> usize { + self.num_dem_outputs + } + + /// Number of tracked Paulis. + #[must_use] + pub fn num_tracked_paulis(&self) -> usize { + self.labels.tracked_paulis.len() + } + + /// Standard observable `L` IDs selected from this sampler. + #[must_use] + pub fn observable_ids(&self) -> Vec { + (0..self.num_dem_outputs).collect() + } + + /// PECOS tracked-Pauli IDs selected from this sampler. + /// + /// Decoder-facing DEM samplers do not directly evaluate tracked Paulis: + /// tracked Paulis are preserved in metadata and in PECOS DEM fault + /// effects, but the sampled bit columns are detectors plus standard + /// observable `L` outputs only. + /// + /// # Errors + /// + /// Returns [`TrackedPauliSamplingError`] when tracked Paulis are + /// present and the caller is asking for a direct sampled tracked-Pauli + /// output space. + pub fn tracked_pauli_ids(&self) -> Result, TrackedPauliSamplingError> { + self.ensure_tracked_pauli_sampling_supported()?; + Ok(Vec::new()) + } + + /// Sample direct tracked-Pauli flips. + /// + /// This returns an empty vector when the sampler carries no tracked + /// Paulis. If tracked Paulis are present, this backend fails + /// explicitly instead of returning silently empty data. + /// + /// # Errors + /// + /// Returns [`TrackedPauliSamplingError`] when tracked Paulis are + /// present because [`DemSampler`] samples detector and observable columns, + /// not tracked-Pauli columns. + pub fn sample_tracked_pauli_flips( + &self, + _rng: &mut R, + ) -> Result, TrackedPauliSamplingError> { + self.ensure_tracked_pauli_sampling_supported()?; + Ok(Vec::new()) + } + + /// Sample direct tracked-Pauli flips for multiple shots. + /// + /// # Errors + /// + /// Returns [`TrackedPauliSamplingError`] when tracked Paulis are + /// present for the same reason as [`Self::sample_tracked_pauli_flips`]. + pub fn sample_tracked_pauli_batch( + &self, + num_shots: usize, + _rng: &mut R, + ) -> Result>, TrackedPauliSamplingError> { + self.ensure_tracked_pauli_sampling_supported()?; + Ok(vec![Vec::new(); num_shots]) + } + + fn ensure_tracked_pauli_sampling_supported(&self) -> Result<(), TrackedPauliSamplingError> { + let num_tracked_paulis = self.num_tracked_paulis(); + if num_tracked_paulis == 0 { + Ok(()) + } else { + Err(TrackedPauliSamplingError::new( + "DemSampler", + num_tracked_paulis, + )) + } + } + + /// 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)) + } + + /// Converts a sampled DEM-output flip vector into an observable-only mask. + #[must_use] + pub fn observable_mask_from_dem_output_flips(&self, flips: &[bool]) -> u64 { + let observable_mask = self.observable_dem_output_mask(); + flips + .iter() + .enumerate() + .filter(|(idx, flipped)| { + **flipped && *idx < u64::BITS as usize && (observable_mask & (1u64 << *idx)) != 0 + }) + .fold(0u64, |acc, (idx, _)| acc | (1u64 << idx)) + } + + /// Number of mechanisms in the sampler. + #[must_use] + pub fn num_mechanisms(&self) -> usize { + self.inner.num_mechanisms() + } + + /// Average mechanism firing probability. + #[must_use] + pub fn average_error_probability(&self) -> f64 { + self.inner.average_error_probability() + } + + /// Maximum mechanism firing probability. + #[must_use] + pub fn max_error_probability(&self) -> f64 { + self.inner.max_error_probability() + } + + /// Get the labels for this sampler's output channels. + #[must_use] + pub fn labels(&self) -> &SamplerLabels { + &self.labels + } + + /// Output mode this sampler was built for. + #[must_use] + pub fn mode(&self) -> OutputMode { + self.mode + } + + /// Finalize raw measurement outputs: expand coordinates, apply non-det coin + /// flips, and propagate deterministic dependencies. + /// + /// This is the single post-processing path for all raw-mode sampling methods. + fn finalize_raw_output(&self, engine_outputs: Vec, rng: &mut R) -> Vec { + // Step 1: Expand engine output to full measurement space if remapping + let mut outputs = if let Some(ref remap) = self.raw_remap { + let mut full = vec![false; self.num_outputs]; + for (engine_idx, &abs_idx) in remap.iter().enumerate() { + if engine_idx < engine_outputs.len() && abs_idx < full.len() { + full[abs_idx] = engine_outputs[engine_idx]; + } + } + full + } else { + engine_outputs + }; + + // Step 2: Add coin flips for non-deterministic measurements + for (i, &is_non_det) in self.non_det_mask.iter().enumerate() { + if is_non_det && i < outputs.len() { + outputs[i] ^= rng.coin_flip(); + } + } + + // Step 3: Propagate deterministic measurement dependencies. + // For m_i with deps {j, k, ...}: m_i XOR= XOR(m_j, m_k, ...) XOR flip + // Dependencies are always to earlier measurements (processed in order). + for i in 0..outputs.len().min(self.measurement_deps.len()) { + if let Some((ref deps, flip)) = self.measurement_deps[i] { + let dep_xor = deps + .iter() + .filter(|&&j| j < outputs.len()) + .fold(flip, |acc, &j| acc ^ outputs[j]); + outputs[i] ^= dep_xor; + } + } + + outputs + } + + /// Sample a single shot. + /// + /// Returns `(outputs, dem_output_flips)` where outputs are either raw + /// measurement values or detector events depending on the mode. + #[must_use] + pub fn sample(&self, rng: &mut R) -> (Vec, Vec) { + let (engine_outputs, dem_outputs) = self.inner.sample(rng); + + let outputs = if self.mode == OutputMode::RawMeasurements { + self.finalize_raw_output(engine_outputs, rng) + } else { + engine_outputs + }; + + (outputs, dem_outputs) + } + + /// Sample multiple shots. + #[must_use] + pub fn sample_batch( + &self, + num_shots: usize, + rng: &mut R, + ) -> (Vec>, Vec>) { + let (engine_batches, all_dem_outputs) = self.inner.sample_batch(num_shots, rng); + + let all_outputs: Vec> = if self.mode == OutputMode::RawMeasurements { + engine_batches + .into_iter() + .map(|engine_out| self.finalize_raw_output(engine_out, rng)) + .collect() + } else { + engine_batches + }; + + (all_outputs, all_dem_outputs) + } + + /// Batch sample using geometric skip — O(fired) instead of O(all mechanisms). + /// + /// Returns columnar bit-packed data: + /// - detector columns: `[num_detectors][ceil(num_shots/64)]` u64 words + /// - `L` target columns: `[num_dem_outputs][ceil(num_shots/64)]` u64 words + /// + /// Much faster than `sample_batch` at low error rates where few mechanisms fire. + /// Only available in detector-event mode (not raw measurement mode). + /// + /// # Panics + /// + /// Panics if the sampler is in raw measurement mode. + #[must_use] + pub fn sample_batch_geometric( + &self, + num_shots: usize, + rng: &mut R, + ) -> (Vec>, Vec>) { + assert!( + self.mode != OutputMode::RawMeasurements, + "sample_batch_geometric() does not support raw measurement mode \ + (requires non-det coin flips + dependency propagation per shot). \ + Use sample_batch() instead." + ); + self.inner.sample_batch_columnar_geometric(num_shots, rng) + } + + /// Sample a single shot and return both raw measurements and detector events. + /// + /// This uses a single RNG sequence to produce both outputs consistently. + /// Requires the sampler to have been built in raw measurement mode with + /// detector definitions stored via the builder. + /// + /// Returns `None` if no detector definitions are available. + #[must_use] + pub fn sample_dual(&self, rng: &mut R) -> Option { + if self.detector_records_abs.is_empty() { + return None; + } + + // Sample mechanism flips in raw measurement coordinates + let (raw_flips, dem_output_flips) = self.inner.sample(rng); + + // Finalize raw measurements (expand, coin flips, dependency propagation) + let raw_measurements = self.finalize_raw_output(raw_flips, rng); + + // Compute detector events from FINALIZED raw measurements + // (includes non-det coin flips and dependency propagation) + let detector_events: Vec = self + .detector_records_abs + .iter() + .map(|record| { + record.iter().fold(false, |acc, &idx| { + acc ^ raw_measurements.get(idx).copied().unwrap_or(false) + }) + }) + .collect(); + + Some(DualSampleResult { + raw_measurements, + detector_events, + dem_output_flips, + }) + } + + /// Compute statistics with a user-provided RNG. + #[must_use] + pub fn sample_statistics_with_rng( + &self, + num_shots: usize, + rng: &mut R, + ) -> super::dem_sampler::SamplingStatistics { + let observable_indices = self.observable_ids(); + self.inner + .sample_statistics_with_rng_for_observable_indices(num_shots, rng, &observable_indices) + } + + /// Compute statistics without storing individual shots. + /// + /// Delegates to [`DemSampler::sample_statistics`] which auto-selects + /// the fastest algorithm. Non-deterministic coin flips do NOT affect + /// statistics since they are independent of faults and cancel in + /// expectation for any well-formed detector. + #[must_use] + pub fn sample_statistics( + &self, + num_shots: usize, + seed: u64, + ) -> super::dem_sampler::SamplingStatistics { + let observable_indices = self.observable_ids(); + self.inner + .sample_statistics_for_observable_indices(num_shots, seed, &observable_indices) + } +} + +// ============================================================================ +// Builder +// ============================================================================ + +/// Builder for [`DemSampler`]. +/// +/// Constructs a sampler from a fault influence map and noise parameters. +/// The output mode (raw measurements vs detector events) is determined by +/// how the builder is configured. +pub struct DemSamplerBuilder<'a> { + influence_map: &'a DagFaultInfluenceMap, + noise: NoiseConfig, + per_gate: Option, + output_mode: OutputMode, + detector_records: Option>>, + observable_records: Option>>, + measurement_order: Option>, + detector_records_abs: Option>>, + labels: SamplerLabels, +} + +impl<'a> DemSamplerBuilder<'a> { + /// Create a new builder. Default mode is raw measurements. + #[must_use] + pub fn new(influence_map: &'a DagFaultInfluenceMap) -> Self { + Self { + influence_map, + noise: NoiseConfig::default(), + per_gate: None, + output_mode: OutputMode::RawMeasurements, + detector_records: None, + observable_records: None, + measurement_order: None, + detector_records_abs: None, + labels: SamplerLabels::default(), + } + } + + /// Set noise parameters. + #[must_use] + pub fn with_noise(mut self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { + self.noise = NoiseConfig::new(p1, p2, p_meas, p_prep); + self + } + + /// Set noise from a `NoiseConfig` (includes `p_idle` if set). + #[must_use] + pub fn with_noise_config(mut self, config: NoiseConfig) -> Self { + self.noise = config; + self + } + + /// Set per-gate-type and optional per-qubit Pauli rates. + #[must_use] + pub fn with_per_gate_noise(mut self, config: PerGateTypeNoise) -> Self { + self.noise = config.base.clone(); + self.per_gate = Some(config); + self + } + + /// Set uniform noise (same probability for all gate types, including idle). + #[must_use] + pub fn with_uniform_noise(self, p: f64) -> Self { + let mut s = self.with_noise(p, p, p, p); + s.noise.p_idle = p; + s + } + + /// Set idle gate noise rate. + #[must_use] + pub fn with_idle_noise(mut self, p_idle: f64) -> Self { + self.noise.p_idle = p_idle; + self + } + + /// Request raw measurement output (default). + /// + /// Each deterministic measurement is its own output channel. Non-deterministic + /// measurements get independent coin flips. + #[must_use] + pub fn raw_measurements(mut self) -> Self { + self.output_mode = OutputMode::RawMeasurements; + self.detector_records = None; + self.observable_records = None; + self + } + + /// Request detector-event output with the given detector/DEM output definitions. + /// + /// Detector records use DEM-style negative offsets: `[-1]` means "the last + /// measurement", `[-3, -1]` means "XOR of the last and third-to-last." + #[must_use] + pub fn with_detectors( + mut self, + detector_records: Vec>, + observable_records: Vec>, + ) -> Self { + self.output_mode = OutputMode::DetectorEvents; + self.detector_records = Some(detector_records); + self.observable_records = Some(observable_records); + self + } + + /// Set detector records directly (without observables). + #[must_use] + pub fn with_detector_records(mut self, records: Vec>) -> Self { + self.output_mode = OutputMode::DetectorEvents; + self.detector_records = Some(records); + if self.observable_records.is_none() { + self.observable_records = Some(Vec::new()); + } + self + } + + /// Set observable definitions directly. + #[must_use] + pub fn with_observable_records(mut self, records: Vec>) -> Self { + self.observable_records = Some(records); + self + } + + /// Set detector definitions from JSON. + /// + /// Format: `[{"id": 0, "records": [-1, -5]}, ...]` + /// + /// # Errors + /// Returns an error if the JSON is malformed. + pub fn with_detectors_json(self, json: &str) -> Result { + let records = parse_records_json(json); + Ok(self.with_detector_records(records)) + } + + /// Set observable definitions from JSON. + /// + /// Format: `[{"id": 0, "records": [-1, -3, -5]}, ...]` + /// + /// # Errors + /// Returns an error if the JSON is malformed. + pub fn with_observables_json(self, json: &str) -> Result { + let records = parse_records_json(json); + Ok(self.with_observable_records(records)) + } + + /// Enable dual output (raw measurements + detector events from same sample). + /// + /// When building in raw measurement mode, stores the detector definitions + /// so that [`DemSampler::sample_dual`] can compute both outputs. + /// The records use absolute measurement indices (not negative offsets). + #[must_use] + pub fn with_dual_output(mut self, detector_records_abs: Vec>) -> Self { + self.detector_records_abs = Some(detector_records_abs); + self + } + + /// Extract detector, observable, and tracked-Pauli definitions from a [`DagCircuit`]'s + /// in-circuit annotations. + /// + /// Extract annotations from a [`DagCircuit`] and configure the sampler. + /// + /// Detector annotations are mapped to auto-detected detector indices. + /// Observables are converted to measurement-record outputs. Tracked + /// Paulis remain unmeasured Pauli annotations and are carried + /// through PECOS metadata only. + #[must_use] + pub fn with_circuit_annotations(mut self, circuit: &pecos_quantum::DagCircuit) -> Self { + use pecos_quantum::AnnotationKind; + + let mut node_to_meas_idx: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for (meas_idx, &(node, _qubit, _basis)) in + self.influence_map.measurements.iter().enumerate() + { + node_to_meas_idx.entry(node).or_insert(meas_idx); + } + + let detectors: Vec<&pecos_quantum::PauliAnnotation> = circuit.detectors().collect(); + let observables: Vec<&pecos_quantum::PauliAnnotation> = circuit.observables().collect(); + + // Map user-defined detector annotations to auto-detected detector indices + if !detectors.is_empty() { + // For each IM measurement index, find which auto-detector contains it + let mut meas_idx_to_auto_det: Vec> = + vec![None; self.influence_map.measurements.len()]; + for (det_idx, det) in self.influence_map.detectors.iter().enumerate() { + for meas_id in &det.measurements { + for (im_idx, &(_node, qubit, basis)) in + self.influence_map.measurements.iter().enumerate() + { + if qubit == meas_id.qubit + && basis == meas_id.basis + && meas_idx_to_auto_det[im_idx].is_none() + { + meas_idx_to_auto_det[im_idx] = Some(det_idx); + break; + } + } + } + } + + // Map each user detector: measurement_nodes → IM meas index → auto-detector index + let det_records_abs: Vec> = detectors + .iter() + .map(|ann| { + if let AnnotationKind::Detector { + measurement_nodes, .. + } = &ann.kind + { + measurement_nodes + .iter() + .filter_map(|&node| { + let im_idx = node_to_meas_idx.get(&node)?; + meas_idx_to_auto_det[*im_idx] + }) + .collect() + } else { + Vec::new() + } + }) + .collect(); + + self.labels.dual_detectors = detectors.iter().map(|a| a.label.clone()).collect(); + self.detector_records_abs = Some(det_records_abs); + } + + if !observables.is_empty() && self.observable_records.is_none() { + let records = if let Ok(num_measurements) = + i32::try_from(self.influence_map.measurements.len()) + { + observables + .iter() + .map(|ann| { + if let AnnotationKind::Observable { measurement_nodes } = &ann.kind { + measurement_nodes + .iter() + .filter_map(|node| node_to_meas_idx.get(node).copied()) + .filter_map(|meas_idx| { + i32::try_from(meas_idx) + .ok() + .map(|meas_idx| meas_idx - num_measurements) + }) + .collect() + } else { + Vec::new() + } + }) + .collect() + } else { + vec![Vec::new(); observables.len()] + }; + self.observable_records = Some(records); + } + + let observable_labels: Vec> = + observables.iter().map(|a| a.label.clone()).collect(); + if !observable_labels.is_empty() { + self.labels.dem_output_labels = observable_labels; + } + + let tracked_pauli_labels: Vec> = circuit + .annotations() + .iter() + .filter(|a| matches!(a.kind, AnnotationKind::TrackedPauli)) + .map(|a| a.label.clone()) + .collect(); + if !tracked_pauli_labels.is_empty() { + self.labels.tracked_pauli_labels = tracked_pauli_labels; + } + + self + } + + /// Set the measurement order for legacy circuits without `MeasId` on gates. + /// + /// **Not needed for circuits built with `TickCircuit.mz()`** — the `MeasId` + /// values on gates ensure correct ordering automatically. + #[must_use] + pub fn with_measurement_order(mut self, order: Vec) -> Self { + self.measurement_order = Some(order); + self + } + + /// Build the sampler. + /// + /// # Errors + /// + /// Returns an error if detector definitions reference non-deterministic + /// measurements or are not linearly independent over `Z_2`. + pub fn build(self) -> Result { + match self.output_mode { + OutputMode::RawMeasurements => Ok(self.build_raw()), + OutputMode::DetectorEvents => self.build_detector(), + } + } + + /// Build in raw measurement mode. + /// + /// Mechanism table is in measurement coordinates. Non-deterministic + /// measurements are identified and marked for coin-flip output. + fn build_raw(self) -> DemSampler { + let num_measurements = self.influence_map.measurements.len(); + + // Build per-location probabilities from gate-type noise + let per_location_probs = self.compute_per_location_probs(); + + // Build mechanism table in raw measurement coordinates + let inner = SamplingEngine::from_influence_map( + self.influence_map, + &per_location_probs, + &self.noise, + ); + + // Identify non-deterministic measurements. + // A measurement is deterministic if the influence builder found it + // as part of a detector definition. If it's NOT in any detector, + // it might be non-deterministic (first-round stabilizer, data readout). + // + // Conservative approach: mark a measurement as non-deterministic if + // it doesn't appear in any detector definition. This isn't perfect + // (some deterministic measurements might not be in detectors) but + // is safe — extra coin flips on deterministic measurements that + // happen to not be in detectors just add noise. + let mut in_detector = vec![false; num_measurements]; + for det in &self.influence_map.detectors { + for m in &det.measurements { + // Find measurement index by matching qubit + tick + for (idx, &(_node, qubit, _basis)) in + self.influence_map.measurements.iter().enumerate() + { + if qubit == m.qubit { + in_detector[idx] = true; + } + } + } + } + let non_det_mask: Vec = in_detector.iter().map(|&in_det| !in_det).collect(); + + let num_dem_outputs = inner.num_dem_outputs(); + let dem_outputs = dem_outputs_from_influence_map(self.influence_map, num_dem_outputs); + let tracked_paulis = tracked_paulis_from_influence_map(self.influence_map); + + DemSampler { + inner, + non_det_mask, + detector_records_abs: self.detector_records_abs.unwrap_or_default(), + mode: OutputMode::RawMeasurements, + num_outputs: num_measurements, + num_dem_outputs, + labels: merge_dem_output_metadata(self.labels, dem_outputs, tracked_paulis), + raw_remap: None, + measurement_deps: Vec::new(), // No expansion needed (engine covers all measurements) + } + } + + /// Build in detector-event mode. + /// + /// Validates detector definitions, then uses `DemSamplerBuilder` to build + /// the mechanism table in detector coordinates. + fn build_detector(self) -> Result { + use super::dem_sampler::SamplingEngineBuilder; + + let num_measurements = self.influence_map.measurements.len(); + + // Validate: check which measurements are deterministic (before partial move) + let deterministic = self.compute_deterministic_mask(); + + let detector_records = self.detector_records.unwrap_or_default(); + let observable_records = self.observable_records.unwrap_or_default(); + let num_detectors = detector_records.len(); + + // Check that all detector records reference deterministic measurements + for (det_id, records) in detector_records.iter().enumerate() { + for &offset in records { + // Resolve offset to an absolute index: negative offsets count + // backward from the end of the measurement list. + #[allow(clippy::cast_sign_loss)] // offset is non-negative in else branch + let abs_idx = if offset < 0 { + let neg = offset.unsigned_abs() as usize; + if neg > num_measurements { + continue; + } + num_measurements - neg + } else { + offset as usize + }; + + if abs_idx < num_measurements && !deterministic[abs_idx] { + return Err(DetectorValidationError::NonDeterministicReference { + detector_id: det_id, + measurement_idx: abs_idx, + }); + } + } + } + + // Check linear independence via Gaussian elimination over Z_2 + if num_detectors > 0 { + let rank = z2_rank_from_records(&detector_records, num_measurements); + if rank < num_detectors { + return Err(DetectorValidationError::LinearlyDependent { + rank, + num_detectors, + }); + } + } + + 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_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 { + builder = builder.with_measurement_order(order); + } + + let inner = builder.build(); + let num_dem_outputs = inner.num_dem_outputs(); + let dem_outputs = + dem_outputs_from_records(self.influence_map, &observable_records, num_dem_outputs); + let tracked_paulis = tracked_paulis_from_influence_map(self.influence_map); + + Ok(DemSampler { + inner, + non_det_mask: Vec::new(), + detector_records_abs: Vec::new(), + mode: OutputMode::DetectorEvents, + num_outputs: num_detectors, + num_dem_outputs, + labels: merge_dem_output_metadata(self.labels, dem_outputs, tracked_paulis), + raw_remap: None, + measurement_deps: Vec::new(), + }) + } + + /// Compute which measurements are deterministic. + /// + /// A measurement is considered deterministic if it appears in at least + /// one detector definition in the influence map. + fn compute_deterministic_mask(&self) -> Vec { + let num_measurements = self.influence_map.measurements.len(); + let mut deterministic = vec![false; num_measurements]; + + for det in &self.influence_map.detectors { + for m in &det.measurements { + for (idx, &(_node, qubit, _basis)) in + self.influence_map.measurements.iter().enumerate() + { + if qubit == m.qubit { + deterministic[idx] = true; + } + } + } + } + + deterministic + } + + /// Compute per-location error probabilities from gate-type noise config. + /// + /// Returns the total error probability per location. For T1/T2 idle noise, + /// this is the sum of the biased Pauli probabilities. + fn compute_per_location_probs(&self) -> Vec { + compute_location_probs_from_noise(&self.influence_map.locations, &self.noise) + } +} + +/// Compute per-location total error probabilities from noise config. +/// +/// For T1/T2 idle noise, returns the sum of biased Pauli probabilities. +/// For all other gates, returns the gate-type probability. +pub(crate) fn compute_location_probs_from_noise( + locations: &[super::super::propagator::dag::DagSpacetimeLocation], + noise: &NoiseConfig, +) -> Vec { + locations + .iter() + .map(|loc| { + #[allow(clippy::match_same_arms)] + match loc.gate_type { + GateType::PZ | GateType::QAlloc => noise.p_prep, + GateType::MZ | GateType::MeasureFree => noise.p_meas, + 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 => 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; + noise.idle_pauli_probs(duration).total() + } else { + 0.0 + } + } + _ => noise.p1, + } + }) + .collect() +} + +/// Get the per-qubit error probability for a gate fault location. +pub(crate) fn gate_location_prob_from_locations( + loc: &super::super::propagator::dag::GateFaultLocation<'_>, + loc_probs: &[f64], + all_locations: &[super::super::propagator::dag::DagSpacetimeLocation], +) -> f64 { + for (i, l) in all_locations.iter().enumerate() { + if l.node == loc.node && l.before == loc.before { + return loc_probs[i]; + } + } + 0.0 +} + +/// Parse detector or observable definitions from JSON. +/// +/// Run noiseless symbolic simulation on a `TickCircuit` to identify non-deterministic measurements. +/// +/// Returns a Vec where true = non-deterministic (needs coin flip). +/// Uses `SymbolicSparseStab` which tracks measurement determinism symbolically. +/// Run noiseless symbolic simulation to identify non-deterministic measurements +/// and their dependency structure. +/// +/// Returns: +/// - `Vec`: non-det mask (true = needs coin flip) +/// - `Vec, bool)>>`: per-measurement dependencies +/// (Some((deps, flip)) for deterministic measurements, None for non-det) +/// +/// Only supports the Clifford gate subset. Returns error for unsupported gates. +fn parse_records_json(json: &str) -> Vec> { + let json = json.trim(); + if json.is_empty() || json == "[]" { + return Vec::new(); + } + + let mut results = Vec::new(); + let mut depth = 0; + let mut start = None; + + for (i, c) in json.char_indices() { + match c { + '{' => { + if depth == 1 { + start = Some(i); + } + depth += 1; + } + '}' => { + depth -= 1; + if depth == 1 { + if let Some(s) = start { + let obj_str = &json[s..i + c.len_utf8()]; + results.push(extract_records_array(obj_str)); + } + start = None; + } + } + '[' if depth == 0 => depth = 1, + ']' if depth == 1 => depth = 0, + _ => {} + } + } + + results +} + +/// Extract measurement record indices from a JSON object string. +/// +/// Prefers `"meas_ids"` (absolute `MeasId` IDs) when available. +/// Also accepts `"records"` for DEM-style negative offsets. +fn extract_records_array(json: &str) -> Vec { + // Prefer meas_ids (absolute, stable IDs from MeasId) + if let Some(pos) = json.find("\"meas_ids\"") { + let rest = &json[pos..]; + if let (Some(arr_start), Some(arr_end)) = (rest.find('['), rest.find(']')) + && arr_start < arr_end + { + let arr_str = &rest[arr_start + 1..arr_end]; + let ids: Vec = arr_str + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + if !ids.is_empty() { + // Convert absolute MeasId IDs to negative offsets: + // not needed — the DemBuilder resolves negative offsets against + // num_measurements. With absolute IDs, we store them as positive + // values and handle them in the DemBuilder's build_measurement_mappings. + // + // For now, keep the negative-offset convention internally but + // convert: absolute ID i becomes offset -(num_measurements - i). + // We don't know num_measurements here, so return the absolute IDs + // as positive i32. The DemBuilder recognizes positive values as + // absolute MeasId indices. + return ids; + } + } + } + + // Fallback: "records" with negative offsets + if let Some(pos) = json.find("\"records\"") { + let rest = &json[pos..]; + if let (Some(arr_start), Some(arr_end)) = (rest.find('['), rest.find(']')) + && arr_start < arr_end + { + let arr_str = &rest[arr_start + 1..arr_end]; + return arr_str + .split(',') + .filter_map(|s| s.trim().parse::().ok()) + .collect(); + } + } + Vec::new() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fault_tolerance::InfluenceBuilder; + use pecos_quantum::DagCircuit; + use pecos_random::PecosRng; + + fn repetition_code(rounds: usize) -> DagCircuit { + let mut dag = DagCircuit::new(); + for _ in 0..rounds { + dag.pz(&[3]); + dag.pz(&[4]); + dag.cx(&[(0, 3)]); + dag.cx(&[(1, 3)]); + dag.cx(&[(1, 4)]); + dag.cx(&[(2, 4)]); + dag.mz(&[3]); + dag.mz(&[4]); + } + dag + } + + #[test] + fn raw_mode_output_length_matches_measurements() { + let circuit = repetition_code(2); + let im = InfluenceBuilder::new(&circuit).with_z(&[0, 1, 2]).build(); + + let sampler = DemSamplerBuilder::new(&im) + .with_uniform_noise(0.01) + .raw_measurements() + .build() + .unwrap(); + + let mut rng = PecosRng::seed_from_u64(42); + let (outputs, _obs) = sampler.sample(&mut rng); + + assert_eq!(outputs.len(), im.measurements.len()); + assert_eq!(sampler.mode(), OutputMode::RawMeasurements); + } + + #[test] + fn zero_noise_raw_mode_deterministic_measurements_are_zero() { + let circuit = repetition_code(3); + let im = InfluenceBuilder::new(&circuit).with_z(&[0, 1, 2]).build(); + + let sampler = DemSamplerBuilder::new(&im) + .with_uniform_noise(0.0) + .raw_measurements() + .build() + .unwrap(); + + // With zero noise, deterministic measurement flips should all be false. + // Non-deterministic ones get coin flips so we can't assert on those. + // But the mechanism-driven part should be all-zero. + let stats = sampler.sample_statistics(1000, 42); + assert_eq!(stats.syndrome_count, 0); + assert_eq!(stats.logical_error_count, 0); + } + + #[test] + fn raw_mode_matches_dem_sampler_from_influence_map() { + let circuit = repetition_code(3); + let im = InfluenceBuilder::new(&circuit).with_z(&[0, 1, 2]).build(); + + let p = 0.01; + let num_shots = 20_000; + + // DemSampler raw mode + let sampler = DemSamplerBuilder::new(&im) + .with_uniform_noise(p) + .raw_measurements() + .build() + .unwrap(); + + let unified_stats = sampler.sample_statistics(num_shots, 42); + + // DemSampler::from_influence_map (same mechanism construction) + let probs = vec![p; im.locations.len()]; + let dem = DemSampler::from_influence_map(&im, &probs); + let dem_stats = dem.sample_statistics(num_shots, 42); + + // Same seed, same mechanism construction → identical results + assert_eq!(unified_stats.syndrome_count, dem_stats.syndrome_count); + assert_eq!( + unified_stats.logical_error_count, + dem_stats.logical_error_count + ); + } + + #[test] + fn detector_mode_output_length_matches_definitions() { + let circuit = repetition_code(3); + let im = InfluenceBuilder::new(&circuit).build(); + + // Define 2 simple detectors (last two measurements) + let detector_records = vec![vec![-1i32], vec![-2]]; + let observable_records = vec![vec![-1i32]]; // 1 observable + + let sampler = DemSamplerBuilder::new(&im) + .with_noise(0.001, 0.01, 0.005, 0.001) + .with_detectors(detector_records, observable_records) + .build() + .unwrap(); + + let mut rng = PecosRng::seed_from_u64(42); + let (det_events, obs_flips) = sampler.sample(&mut rng); + + assert_eq!(det_events.len(), 2); + assert_eq!(obs_flips.len(), 1); + assert_eq!(sampler.mode(), OutputMode::DetectorEvents); + } + + #[test] + fn detector_mode_accepts_observable_aliases() { + let circuit = repetition_code(3); + let im = InfluenceBuilder::new(&circuit).build(); + + let records_sampler = DemSamplerBuilder::new(&im) + .with_detector_records(vec![vec![-1]]) + .with_observable_records(vec![vec![-1]]) + .build() + .unwrap(); + + assert_eq!(records_sampler.num_detectors(), 1); + assert_eq!(records_sampler.num_dem_outputs(), 1); + assert_eq!(records_sampler.num_observables(), 1); + assert_eq!(records_sampler.num_tracked_paulis(), 0); + assert_eq!(records_sampler.mode(), OutputMode::DetectorEvents); + + let json_sampler = DemSamplerBuilder::new(&im) + .with_detectors_json(r#"[{"id":0,"records":[-1]}]"#) + .unwrap() + .with_observables_json(r#"[{"id":0,"records":[-1]}]"#) + .unwrap() + .build() + .unwrap(); + + assert_eq!(json_sampler.num_detectors(), 1); + assert_eq!(json_sampler.num_dem_outputs(), 1); + assert_eq!(json_sampler.num_observables(), 1); + assert_eq!(json_sampler.num_tracked_paulis(), 0); + assert_eq!(json_sampler.mode(), OutputMode::DetectorEvents); + } + + #[test] + fn from_circuit_preserves_tracked_paulis() { + use crate::fault_tolerance::dem_builder::NoiseConfig; + use pecos_core::pauli::X; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0]); + circuit.h(&[0]); + circuit.tracked_pauli_labeled("x_check", X(0)); + + let noise = NoiseConfig::new(0.03, 0.0, 0.0, 0.0); + let sampler = DemSampler::from_circuit(&circuit, &noise).unwrap(); + + assert_eq!(sampler.num_tracked_paulis(), 1); + assert_eq!(sampler.num_observables(), 0); + assert_eq!( + sampler.labels().tracked_pauli_labels[0].as_deref(), + Some("x_check") + ); + let op = sampler.labels().tracked_paulis[0].as_ref().unwrap(); + assert_eq!(op.label.as_deref(), Some("x_check")); + assert_eq!( + op.kind, + Some(crate::fault_tolerance::DemOutputKind::TrackedPauli) + ); + assert_eq!(op.pauli.as_ref().unwrap().to_sparse_str(), "+X0"); + } + + #[test] + fn detector_mode_keeps_observables_unshifted_with_tracked_paulis() { + use pecos_core::pauli::X; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0]); + circuit.h(&[0]); + circuit.tracked_pauli_labeled("x_check", X(0)); + circuit.mz(&[0]); + + let im = InfluenceBuilder::new(&circuit) + .with_circuit_annotations(&circuit) + .build(); + + let sampler = DemSamplerBuilder::new(&im) + .with_noise(0.03, 0.0, 0.02, 0.0) + .with_detectors(Vec::new(), vec![vec![-1]]) + .build() + .unwrap(); + + assert_eq!(sampler.num_dem_outputs(), 1); + assert_eq!(sampler.num_observables(), 1); + assert_eq!(sampler.num_tracked_paulis(), 1); + assert_eq!(sampler.labels().dem_outputs.len(), 1); + assert_eq!( + sampler.labels().dem_outputs[0].as_ref().unwrap().kind, + Some(crate::fault_tolerance::DemOutputKind::Observable) + ); + assert_eq!( + sampler.labels().tracked_paulis[0].as_ref().unwrap().kind, + Some(crate::fault_tolerance::DemOutputKind::TrackedPauli) + ); + } + + #[test] + fn detector_mode_does_not_double_apply_annotation_observable_records() { + let mut circuit = DagCircuit::new(); + circuit.pz(&[0]); + let meas = circuit.mz(&[0]); + circuit.observable_labeled("obs0", &[meas[0]]); + + let im = InfluenceBuilder::new(&circuit) + .with_circuit_annotations(&circuit) + .build(); + + let sampler = DemSamplerBuilder::new(&im) + .with_noise(0.0, 0.0, 1.0, 0.0) + .with_detectors(Vec::new(), vec![vec![-1]]) + .build() + .unwrap(); + + assert_eq!(sampler.num_dem_outputs(), 1); + assert_eq!(sampler.num_observables(), 1); + assert_eq!(sampler.num_tracked_paulis(), 0); + assert_eq!( + sampler.labels().dem_outputs[0] + .as_ref() + .unwrap() + .label + .as_deref(), + Some("obs0") + ); + assert_eq!( + sampler.labels().dem_outputs[0] + .as_ref() + .unwrap() + .records + .as_slice(), + &[-1] + ); + + let mut rng = PecosRng::seed_from_u64(42); + let (_detectors, observables) = sampler.sample(&mut rng); + assert_eq!(observables, vec![true]); + } + + #[test] + fn from_detector_error_model_preserves_observable_and_tracked_pauli_split() { + use super::super::builder::DemBuilder; + use pecos_core::pauli::X; + use pecos_quantum::Attribute; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0]); + circuit.h(&[0]); + circuit.tracked_pauli_labeled("x_check", X(0)); + circuit.mz(&[0]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "observables", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + + 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(), 1); + assert_eq!(sampler.num_observables(), 1); + assert_eq!(sampler.num_tracked_paulis(), 1); + assert_eq!( + sampler.labels().dem_outputs[0].as_ref().unwrap().kind, + Some(crate::fault_tolerance::DemOutputKind::Observable) + ); + assert_eq!( + sampler.labels().tracked_paulis[0].as_ref().unwrap().kind, + Some(crate::fault_tolerance::DemOutputKind::TrackedPauli) + ); + } + + #[test] + fn sampler_paths_preserve_output_split_for_noiseless_and_forced_faults() { + use super::super::builder::DemBuilder; + use super::super::types::NoiseConfig; + use pecos_core::pauli::X; + use pecos_quantum::Attribute; + + fn assert_metadata(sampler: &DemSampler) { + assert_eq!(sampler.num_detectors(), 1); + assert_eq!(sampler.num_dem_outputs(), 1); + assert_eq!(sampler.num_observables(), 1); + assert_eq!(sampler.num_tracked_paulis(), 1); + assert_eq!(sampler.observable_ids(), vec![0]); + let err = sampler.tracked_pauli_ids().unwrap_err(); + assert_eq!(err.backend(), "DemSampler"); + assert_eq!(err.num_tracked_paulis(), 1); + assert!( + err.to_string() + .contains("cannot directly sample tracked Pauli flips") + ); + assert_eq!( + sampler.labels().dem_outputs[0] + .as_ref() + .unwrap() + .label + .as_deref(), + Some("obs0") + ); + assert_eq!( + sampler.labels().tracked_paulis[0] + .as_ref() + .unwrap() + .label + .as_deref(), + Some("tracked_x0") + ); + } + + fn sample_once(sampler: &DemSampler) -> (Vec, Vec) { + let mut rng = PecosRng::seed_from_u64(123); + sampler.sample(&mut rng) + } + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0]); + let meas = circuit.mz(&[0]); + circuit.detector_labeled("det0", &[meas[0]]); + circuit.observable_labeled("obs0", &[meas[0]]); + circuit.tracked_pauli_labeled("tracked_x0", X(0)); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "detectors", + Attribute::String(r#"[{"id":0,"records":[-1],"label":"det0"}]"#.to_string()), + ); + circuit.set_attr( + "observables", + Attribute::String(r#"[{"id":0,"records":[-1],"label":"obs0"}]"#.to_string()), + ); + + let noiseless = DemSampler::from_circuit(&circuit, &NoiseConfig::default()).unwrap(); + assert_metadata(&noiseless); + assert_eq!(sample_once(&noiseless), (vec![false], vec![false])); + + let forced_noise = NoiseConfig::new(0.0, 0.0, 1.0, 0.0); + let from_circuit = DemSampler::from_circuit(&circuit, &forced_noise).unwrap(); + assert_metadata(&from_circuit); + assert_eq!(sample_once(&from_circuit), (vec![true], vec![true])); + + let dem = DemBuilder::from_circuit(&circuit, 0.0, 0.0, 1.0, 0.0); + let from_dem = DemSampler::from_detector_error_model(&dem); + assert_metadata(&from_dem); + assert_eq!(sample_once(&from_dem), (vec![true], vec![true])); + + let influence_map = InfluenceBuilder::new(&circuit) + .with_circuit_annotations(&circuit) + .build(); + let from_builder = DemSamplerBuilder::new(&influence_map) + .with_noise(0.0, 0.0, 1.0, 0.0) + .with_detector_records(vec![vec![-1]]) + .with_observable_records(vec![vec![-1]]) + .build() + .unwrap(); + assert_metadata(&from_builder); + assert_eq!(sample_once(&from_builder), (vec![true], vec![true])); + } + + #[test] + fn sampler_xors_detectors_and_observables_while_tracked_paulis_stay_metadata() { + use super::super::types::{DetectorDef, DetectorErrorModel, FaultMechanism}; + use pecos_core::pauli::Z; + + let mut dem = DetectorErrorModel::new(); + dem.add_detector(DetectorDef::new(0)); + dem.add_observable(DemOutput::new(0).with_records([-1]).with_label("L0")); + dem.add_tracked_pauli(DemOutput::new(0).with_pauli(Z(3)).with_label("tracked_z3")); + dem.add_direct_contribution(FaultMechanism::from_unsorted([0], [0]), 1.0); + dem.add_direct_contribution(FaultMechanism::from_unsorted([0], []), 1.0); + + let sampler = DemSampler::from_detector_error_model(&dem); + let mut rng = PecosRng::seed_from_u64(99); + + assert_eq!(sampler.num_detectors(), 1); + assert_eq!(sampler.num_observables(), 1); + assert_eq!(sampler.num_tracked_paulis(), 1); + assert_eq!( + sampler.labels().tracked_paulis[0] + .as_ref() + .unwrap() + .label + .as_deref(), + Some("tracked_z3") + ); + assert_eq!(sampler.sample(&mut rng), (vec![false], vec![true])); + } + + #[test] + fn raw_mode_without_dem_outputs_reports_zero_dem_outputs() { + let mut circuit = DagCircuit::new(); + circuit.pz(&[0]); + circuit.h(&[0]); + circuit.mz(&[0]); + let im = InfluenceBuilder::new(&circuit).build(); + + let sampler = DemSamplerBuilder::new(&im) + .with_uniform_noise(0.01) + .raw_measurements() + .build() + .unwrap(); + + assert_eq!(sampler.num_dem_outputs(), 0); + assert_eq!(sampler.num_observables(), 0); + assert_eq!(sampler.num_tracked_paulis(), 0); + } + + #[test] + fn observable_mask_ignores_tracked_pauli_outputs() { + use super::super::builder::DemBuilder; + use pecos_core::pauli::X; + use pecos_quantum::Attribute; + + let mut circuit = DagCircuit::new(); + circuit.pz(&[0]); + circuit.h(&[0]); + circuit.tracked_pauli_labeled("x_check", X(0)); + circuit.mz(&[0]); + circuit.set_attr("num_measurements", Attribute::String("1".to_string())); + circuit.set_attr( + "observables", + Attribute::String(r#"[{"id":0,"records":[-1]}]"#.to_string()), + ); + + 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.observable_ids(), vec![0]); + assert_eq!( + sampler + .tracked_pauli_ids() + .unwrap_err() + .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); + } + + #[test] + fn tracked_pauli_direct_sampling_fails_explicitly_when_unsupported() { + use super::super::types::{DetectorErrorModel, FaultMechanism}; + use pecos_core::pauli::X; + + let mut dem = DetectorErrorModel::new(); + dem.add_tracked_pauli(DemOutput::new(0).with_pauli(X(0)).with_label("tracked_x0")); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([], [], [0]), + 0.25, + ); + + let sampler = DemSampler::from_detector_error_model(&dem); + let mut rng = PecosRng::seed_from_u64(17); + + let err = sampler + .sample_tracked_pauli_flips(&mut rng) + .expect_err("DemSampler should reject direct tracked-Pauli sampling"); + assert_eq!(err.backend(), "DemSampler"); + assert_eq!(err.num_tracked_paulis(), 1); + assert!( + err.to_string() + .contains("samples decoder-facing detectors and observables only") + ); + + let err = sampler + .sample_tracked_pauli_batch(4, &mut rng) + .expect_err("DemSampler should reject direct tracked-Pauli batch sampling"); + assert_eq!(err.num_tracked_paulis(), 1); + + let empty = DemSampler::from_detector_error_model(&DetectorErrorModel::new()); + assert_eq!( + empty.sample_tracked_pauli_flips(&mut rng).unwrap(), + Vec::::new() + ); + assert_eq!( + empty.sample_tracked_pauli_batch(3, &mut rng).unwrap(), + vec![Vec::::new(), Vec::new(), Vec::new()] + ); + } + + #[test] + fn high_noise_produces_nonzero_rates_both_modes() { + let circuit = repetition_code(2); + let im = InfluenceBuilder::new(&circuit).with_z(&[0, 1, 2]).build(); + + let p = 0.1; + let num_shots = 5_000; + + // Raw mode + let raw_sampler = DemSamplerBuilder::new(&im) + .with_uniform_noise(p) + .raw_measurements() + .build() + .unwrap(); + let raw_stats = raw_sampler.sample_statistics(num_shots, 42); + assert!( + raw_stats.syndrome_rate() > 0.05, + "Raw mode should detect syndromes at p=0.1" + ); + + // Detector mode with simple detectors + let detector_records = vec![vec![-1i32], vec![-2]]; + let observable_records: Vec> = vec![]; + let det_sampler = DemSamplerBuilder::new(&im) + .with_uniform_noise(p) + .with_detectors(detector_records, observable_records) + .build() + .unwrap(); + let det_stats = det_sampler.sample_statistics(num_shots, 42); + assert!( + det_stats.syndrome_rate() > 0.05, + "Detector mode should detect syndromes at p=0.1" + ); + } + + #[test] + fn dual_output_returns_none_without_definitions() { + let circuit = repetition_code(2); + let im = InfluenceBuilder::new(&circuit).with_z(&[0, 1, 2]).build(); + + let sampler = DemSamplerBuilder::new(&im) + .with_uniform_noise(0.01) + .raw_measurements() + .build() + .unwrap(); + + let mut rng = PecosRng::seed_from_u64(42); + assert!(sampler.sample_dual(&mut rng).is_none()); + } + + #[test] + fn dual_output_produces_both_views() { + let circuit = repetition_code(3); + let im = InfluenceBuilder::new(&circuit).with_z(&[0, 1, 2]).build(); + + // Define detectors: first and second measurements + let det_defs = vec![vec![0usize], vec![1]]; + + let sampler = DemSamplerBuilder::new(&im) + .with_uniform_noise(0.05) + .raw_measurements() + .with_dual_output(det_defs) + .build() + .unwrap(); + + let mut rng = PecosRng::seed_from_u64(42); + let result = sampler.sample_dual(&mut rng).unwrap(); + + // Raw measurements should have length = num measurements + assert_eq!(result.raw_measurements.len(), im.measurements.len()); + // Detector events should have length = 2 (our 2 detector defs) + assert_eq!(result.detector_events.len(), 2); + } + + #[test] + fn dual_output_detector_events_consistent_with_raw() { + let circuit = repetition_code(3); + let im = InfluenceBuilder::new(&circuit).with_z(&[0, 1, 2]).build(); + + // Detector = XOR of measurements 0 and 1 + let det_defs = vec![vec![0usize, 1]]; + + let sampler = DemSamplerBuilder::new(&im) + .with_uniform_noise(0.1) + .raw_measurements() + .with_dual_output(det_defs) + .build() + .unwrap(); + + // Run many shots and verify detector = raw[0] XOR raw[1] + let mut rng = PecosRng::seed_from_u64(42); + for _ in 0..100 { + let result = sampler.sample_dual(&mut rng).unwrap(); + let expected_det = result.raw_measurements[0] ^ result.raw_measurements[1]; + assert_eq!( + result.detector_events[0], expected_det, + "Detector event should equal XOR of raw measurements 0 and 1" + ); + } + } +} 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 a7d466064..3deda7745 100644 --- a/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/dem_builder/types.rs @@ -13,20 +13,36 @@ //! Types for Detector Error Model (DEM) generation. //! //! This module provides data structures for representing fault mechanisms, -//! detectors, and logical observables in DEM format. +//! detectors, observables, and PECOS tracked Paulis. +//! +//! # Terminology +//! +//! - **Detectors** are syndrome bits defined by measurement-record parity. +//! - **Observables** are values observed through measurements. In a DEM they are +//! defined by measurement records and rendered as standard `L` observable +//! outputs. +//! - **Tracked Paulis** are not measured values and are not applied to the +//! simulated computation. They are Pauli operators annotated at a circuit point +//! (for example a logical operator, stabilizer, or other Pauli of interest); +//! PECOS reports whether each fault event anticommutes with, and therefore +//! would flip, that operator under propagation. +//! +//! PECOS keeps the standard `L` namespace reserved for measurement-record +//! observables only. Tracked Paulis are PECOS metadata with their own +//! ID space, so decoders can ignore them while PECOS tools can still inspect +//! them. //! //! # Output Formats //! //! The DEM supports two output formats: //! -//! - [`DetectorErrorModel::to_string()`] - Non-decomposed format matching Stim's -//! `decompose_errors=False` output. Each mechanism is output once with its -//! combined probability. +//! - [`DetectorErrorModel::to_string()`] - Non-decomposed format. Each +//! mechanism is output once with its combined probability. //! -//! - [`DetectorErrorModel::to_string_decomposed()`] - Decomposed format matching -//! Stim's `decompose_errors=True` output. Hyperedge errors (3+ detectors) are -//! decomposed into graphlike components, and 2-detector mechanisms may have -//! multiple representations for decoder compatibility. +//! - [`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. //! //! Decomposed errors use the `^` separator to indicate XOR composition: //! @@ -37,6 +53,7 @@ //! This indicates an error decomposed into two parts whose XOR equals the //! original mechanism. +use pecos_core::PauliString; use pecos_core::gate_type::GateType; use rand::RngExt; use smallvec::SmallVec; @@ -45,7 +62,7 @@ use std::collections::{BTreeMap, BTreeSet}; use std::fmt; use std::hash::{Hash, Hasher}; -use crate::fault_tolerance::propagator::Pauli; +use crate::fault_tolerance::propagator::{DemOutputKind, DemOutputMetadata, Pauli}; // ============================================================================ // Error Source Tracking @@ -78,12 +95,12 @@ pub enum FaultSourceType { YDecomposed { /// Detector effect of the X component (sorted detector IDs). x_detectors: SmallVec<[u32; 4]>, - /// Logical effect of the X component (sorted logical IDs). - x_logicals: SmallVec<[u32; 2]>, + /// DEM-output effect of the X component (sorted `L` IDs). + x_dem_outputs: SmallVec<[u32; 2]>, /// Detector effect of the Z component (sorted detector IDs). z_detectors: SmallVec<[u32; 4]>, - /// Logical effect of the Z component (sorted logical IDs). - z_logicals: SmallVec<[u32; 2]>, + /// DEM-output effect of the Z component (sorted `L` IDs). + z_dem_outputs: SmallVec<[u32; 2]>, }, } @@ -121,7 +138,7 @@ pub enum DirectSourceFamily { /// determining how they are output (direct vs decomposed forms). #[derive(Debug, Clone)] pub struct FaultContribution { - /// The detector/logical effect of this error. + /// The detector/DEM-output effect of this error. pub effect: FaultMechanism, /// Probability of this error. @@ -319,9 +336,9 @@ impl FaultContribution { probability, source_type: FaultSourceType::YDecomposed { x_detectors: x_effect.detectors.clone(), - x_logicals: x_effect.logicals.clone(), + x_dem_outputs: x_effect.dem_outputs.clone(), z_detectors: z_effect.detectors.clone(), - z_logicals: z_effect.logicals.clone(), + z_dem_outputs: z_effect.dem_outputs.clone(), }, location_indices: SmallVec::new(), paulis: SmallVec::new(), @@ -349,9 +366,9 @@ impl FaultContribution { probability, source_type: FaultSourceType::YDecomposed { x_detectors: x_effect.detectors.clone(), - x_logicals: x_effect.logicals.clone(), + x_dem_outputs: x_effect.dem_outputs.clone(), z_detectors: z_effect.detectors.clone(), - z_logicals: z_effect.logicals.clone(), + z_dem_outputs: z_effect.dem_outputs.clone(), }, location_indices: source.location_indices.iter().copied().collect(), paulis: source.paulis.iter().copied().collect(), @@ -377,12 +394,12 @@ impl FaultContribution { match &self.source_type { FaultSourceType::YDecomposed { x_detectors, - x_logicals, + x_dem_outputs, z_detectors, - z_logicals, + z_dem_outputs, } => { - let x = FaultMechanism::from_sorted(x_detectors.clone(), x_logicals.clone()); - let z = FaultMechanism::from_sorted(z_detectors.clone(), z_logicals.clone()); + let x = FaultMechanism::from_sorted(x_detectors.clone(), x_dem_outputs.clone()); + let z = FaultMechanism::from_sorted(z_detectors.clone(), z_dem_outputs.clone()); Some((x, z)) } FaultSourceType::Direct | FaultSourceType::DirectOneSidedComponent => None, @@ -399,7 +416,7 @@ impl FaultContribution { /// Aggregated source-tracked information for one unique effect. #[derive(Debug, Clone)] pub struct ContributionEffectSummary { - /// The detector/logical effect being summarized. + /// The detector/DEM-output effect being summarized. pub effect: FaultMechanism, /// Total number of contributing sources for this effect. pub num_contributions: usize, @@ -415,7 +432,7 @@ pub struct ContributionEffectSummary { pub y_decomposed_probability: f64, /// Number of builder-marked graphlike-decomposable two-qubit sources for this effect. /// - /// This is only non-zero for 2-detector, 0-logical effects. It reflects the + /// This is only non-zero for 2-detector, 0-DEM-output effects. It reflects the /// dormant representation-diversity bookkeeping recorded by the DEM builder. pub graphlike_decomposable_count: u32, } @@ -423,7 +440,7 @@ pub struct ContributionEffectSummary { /// Structured summary of how tracked contributions render before final regrouping. #[derive(Debug, Clone)] pub struct ContributionRenderSummary { - /// Original full detector/logical effect before rendering. + /// Original full detector/DEM-output effect before rendering. pub effect: FaultMechanism, /// Rendered targets string that this contribution group maps to. pub rendered_targets: String, @@ -471,7 +488,7 @@ pub enum ContributionRenderStrategy { SourceComponents, /// Used recorded direct-source component targets instead of the direct edge. RecordedComponents, - /// Kept a 2-detector, 0-logical effect graphlike as-is. + /// Kept a 2-detector, 0-DEM-output effect graphlike as-is. TwoDetectorDirect, /// Decomposed a hyperedge using graphlike effect decomposition. HyperedgeGraphlike, @@ -493,18 +510,25 @@ pub enum TwoDetectorDirectRenderPolicy { // Error Mechanism // ============================================================================ -/// An fault mechanism: a set of detectors and logical observables that flip together. +/// A fault mechanism: a set of detectors and `L` targets that flip together. /// /// When an error occurs, it flips a specific set of detectors and may flip -/// logical observables. Mechanisms with the same effect are aggregated together. +/// `L` targets. Mechanisms with the same effect are aggregated together. /// -/// The detectors and logicals are stored in sorted order for canonical representation. +/// Detector and `L` target indices are stored in sorted order for canonical representation. #[derive(Clone, Default)] pub struct FaultMechanism { /// Detector indices that flip together (sorted). pub detectors: SmallVec<[u32; 4]>, - /// Logical observable indices that flip together (sorted). - pub logicals: SmallVec<[u32; 2]>, + /// DEM `L` target indices that flip together (sorted). + /// + /// New code should treat these as standard observable `L` output channels. + pub dem_outputs: SmallVec<[u32; 2]>, + /// PECOS tracked-Pauli indices that flip together (sorted). + /// + /// These are rendered as `TP` only in PECOS DEM text. Standard DEM text + /// and decoder-facing mechanism tables intentionally ignore them. + pub tracked_paulis: SmallVec<[u32; 2]>, } impl FaultMechanism { @@ -514,36 +538,64 @@ impl FaultMechanism { Self::default() } - /// Creates a mechanism from unsorted detector and logical indices. + /// Creates a mechanism from unsorted detector and DEM-output indices. #[must_use] pub fn from_unsorted( detectors: impl IntoIterator, - logicals: impl IntoIterator, + dem_outputs: impl IntoIterator, + ) -> Self { + Self::from_unsorted_with_tracked_paulis(detectors, dem_outputs, std::iter::empty()) + } + + /// Creates a mechanism from unsorted detector, DEM-output, and tracked-Pauli indices. + #[must_use] + pub fn from_unsorted_with_tracked_paulis( + detectors: impl IntoIterator, + dem_outputs: impl IntoIterator, + tracked_paulis: impl IntoIterator, ) -> Self { let mut dets: SmallVec<[u32; 4]> = detectors.into_iter().collect(); - let mut logs: SmallVec<[u32; 2]> = logicals.into_iter().collect(); + let mut dem_outputs: SmallVec<[u32; 2]> = dem_outputs.into_iter().collect(); + let mut tracked_paulis: SmallVec<[u32; 2]> = tracked_paulis.into_iter().collect(); dets.sort_unstable(); - logs.sort_unstable(); + dem_outputs.sort_unstable(); + tracked_paulis.sort_unstable(); Self { detectors: dets, - logicals: logs, + dem_outputs, + tracked_paulis, } } - /// Creates a mechanism from pre-sorted detector and logical indices. + /// Creates a mechanism from pre-sorted detector and DEM-output indices. + #[must_use] + pub fn from_sorted(detectors: SmallVec<[u32; 4]>, dem_outputs: SmallVec<[u32; 2]>) -> Self { + Self::from_sorted_with_tracked_paulis(detectors, dem_outputs, SmallVec::new()) + } + + /// Creates a mechanism from pre-sorted detector, DEM-output, and tracked-Pauli indices. #[must_use] - pub fn from_sorted(detectors: SmallVec<[u32; 4]>, logicals: SmallVec<[u32; 2]>) -> Self { + pub fn from_sorted_with_tracked_paulis( + detectors: SmallVec<[u32; 4]>, + dem_outputs: SmallVec<[u32; 2]>, + tracked_paulis: SmallVec<[u32; 2]>, + ) -> Self { debug_assert!( detectors.windows(2).all(|w| w[0] <= w[1]), "detectors must be sorted" ); debug_assert!( - logicals.windows(2).all(|w| w[0] <= w[1]), - "logicals must be sorted" + dem_outputs.windows(2).all(|w| w[0] <= w[1]), + "dem_outputs must be sorted" + ); + debug_assert!( + tracked_paulis.windows(2).all(|w| w[0] <= w[1]), + "tracked_paulis must be sorted" ); Self { detectors, - logicals, + dem_outputs, + tracked_paulis, } } @@ -551,7 +603,27 @@ impl FaultMechanism { #[inline] #[must_use] pub fn is_empty(&self) -> bool { - self.detectors.is_empty() && self.logicals.is_empty() + self.detectors.is_empty() && self.dem_outputs.is_empty() && self.tracked_paulis.is_empty() + } + + /// Returns true if this mechanism has no decoder-facing effect. + /// + /// This ignores PECOS tracked-Pauli effects, matching standard DEM and + /// decoder-facing sampler behavior. + #[inline] + #[must_use] + pub fn is_standard_empty(&self) -> bool { + self.detectors.is_empty() && self.dem_outputs.is_empty() + } + + /// Returns the decoder-facing projection of this mechanism. + #[must_use] + pub fn standard_effect(&self) -> Self { + Self { + detectors: self.detectors.clone(), + dem_outputs: self.dem_outputs.clone(), + tracked_paulis: SmallVec::new(), + } } /// Returns the number of detectors in this mechanism. @@ -561,11 +633,18 @@ impl FaultMechanism { self.detectors.len() } - /// Returns the number of logicals in this mechanism. + /// Returns the number of outputs in the DEM `L` namespace. + #[inline] + #[must_use] + pub fn num_dem_outputs(&self) -> usize { + self.dem_outputs.len() + } + + /// Returns the number of tracked Pauli outputs in this mechanism. #[inline] #[must_use] - pub fn num_logicals(&self) -> usize { - self.logicals.len() + pub fn num_tracked_paulis(&self) -> usize { + self.tracked_paulis.len() } /// XOR this mechanism with another, returning the combined effect. @@ -575,15 +654,16 @@ impl FaultMechanism { pub fn xor(&self, other: &Self) -> Self { Self { detectors: symmetric_difference_4(&self.detectors, &other.detectors), - logicals: symmetric_difference_2(&self.logicals, &other.logicals), + dem_outputs: symmetric_difference_2(&self.dem_outputs, &other.dem_outputs), + tracked_paulis: symmetric_difference_2(&self.tracked_paulis, &other.tracked_paulis), } } /// Returns true if this mechanism is graphlike. /// /// A graphlike mechanism has at most 2 detectors. - /// Logical observables do not affect graph-likeness; MWPM decoders attach - /// them as frame-change masks on graph edges. + /// DEM outputs do not affect graph-likeness; MWPM decoders attach them as + /// frame-change masks on graph edges. #[inline] #[must_use] pub fn is_graphlike(&self) -> bool { @@ -661,7 +741,9 @@ fn symmetric_difference_2(a: &SmallVec<[u32; 2]>, b: &SmallVec<[u32; 2]>) -> Sma impl PartialEq for FaultMechanism { fn eq(&self, other: &Self) -> bool { - self.detectors == other.detectors && self.logicals == other.logicals + self.detectors == other.detectors + && self.dem_outputs == other.dem_outputs + && self.tracked_paulis == other.tracked_paulis } } @@ -670,7 +752,8 @@ impl Eq for FaultMechanism {} impl Hash for FaultMechanism { fn hash(&self, state: &mut H) { self.detectors.hash(state); - self.logicals.hash(state); + self.dem_outputs.hash(state); + self.tracked_paulis.hash(state); } } @@ -684,7 +767,8 @@ impl Ord for FaultMechanism { fn cmp(&self, other: &Self) -> Ordering { self.detectors .cmp(&other.detectors) - .then_with(|| self.logicals.cmp(&other.logicals)) + .then_with(|| self.dem_outputs.cmp(&other.dem_outputs)) + .then_with(|| self.tracked_paulis.cmp(&other.tracked_paulis)) } } @@ -692,9 +776,10 @@ impl fmt::Debug for FaultMechanism { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { write!( f, - "FaultMechanism(dets={:?}, logs={:?})", + "FaultMechanism(dets={:?}, dem_outputs={:?}, tracked_paulis={:?})", self.detectors.as_slice(), - self.logicals.as_slice() + self.dem_outputs.as_slice(), + self.tracked_paulis.as_slice() ) } } @@ -754,16 +839,17 @@ impl DecomposedFault { pub fn to_stim_targets(&self) -> String { self.components .iter() - .map(|comp| { - let mut targets = Vec::new(); - for &det in &comp.detectors { - targets.push(format!("D{det}")); - } - for &log in &comp.logicals { - targets.push(format!("L{log}")); - } - targets.join(" ") - }) + .map(format_mechanism_targets) + .collect::>() + .join(" ^ ") + } + + /// Formats this error for PECOS DEM output, including tracked Pauli `TP` targets. + #[must_use] + pub fn to_pecos_targets(&self) -> String { + self.components + .iter() + .map(format_pecos_mechanism_targets) .collect::>() .join(" ^ ") } @@ -792,19 +878,18 @@ impl DecomposedFault { /// # Algorithm /// /// Uses a detector-driven recursive search over graphlike components whose -/// detector sets are subsets of the hyperedge. This is closer to Stim's -/// decomposition strategy than the older fixed-width 2-part/3-part search, -/// and it allows decompositions into 4+ graphlike pieces when needed. +/// detector sets are subsets of the hyperedge. This is more general than the +/// older fixed-width 2-part/3-part search, and it allows decompositions into 4+ +/// graphlike pieces when needed. /// /// Decompositions are filtered to only include components whose detectors are -/// subsets of the original hyperedge's detectors, matching Stim's behavior of -/// not introducing extra detector symptoms. +/// subsets of the original hyperedge's detectors, so decomposition does not +/// introduce extra detector symptoms. /// /// # Selection /// /// The search returns the first valid decomposition found using a deterministic -/// ordering that prefers detector pairs before singlets, similar to Stim's -/// decompose pass over known graphlike symptoms. +/// ordering that prefers detector pairs before singlets. #[cfg(test)] fn find_hyperedge_decomposition( hyperedge: &FaultMechanism, @@ -965,7 +1050,7 @@ fn find_singleton_decomposition( /// before this was lifted out. struct SingletonDecompositionIndex { /// `candidates_by_detector[det]` lists every singleton mechanism whose sole - /// detector is `det`, sorted by `(logicals.len, logicals, detectors)` so the + /// detector is `det`, sorted by `(dem_outputs.len, dem_outputs, detectors)` so the /// decomposition search prefers simpler candidates deterministically. candidates_by_detector: Vec>, } @@ -997,10 +1082,10 @@ impl SingletonDecompositionIndex { } for candidates in &mut candidates_by_detector { candidates.sort_by(|a, b| { - a.logicals + a.dem_outputs .len() - .cmp(&b.logicals.len()) - .then_with(|| a.logicals.cmp(&b.logicals)) + .cmp(&b.dem_outputs.len()) + .then_with(|| a.dem_outputs.cmp(&b.dem_outputs)) .then_with(|| a.detectors.cmp(&b.detectors)) }); } @@ -1063,9 +1148,9 @@ fn shares_element(a: &FaultMechanism, b: &FaultMechanism) -> bool { return true; } } - // Check logicals - for l in &a.logicals { - if b.logicals.contains(l) { + // Check dem_outputs + for l in &a.dem_outputs { + if b.dem_outputs.contains(l) { return true; } } @@ -1079,7 +1164,7 @@ fn convert_location_indices(location_indices: &[usize]) -> SmallVec<[u32; 2]> { .collect() } -/// Converts a measurement record offset (Stim-style) to an absolute measurement index. +/// Converts a DEM measurement-record offset to an absolute measurement index. /// /// Negative offsets count backward from the end of the measurement record /// (`-1` is the last measurement). Positive offsets are treated as absolute @@ -1149,54 +1234,363 @@ impl DetectorDef { } // ============================================================================ -// Logical Observable +// DEM Outputs // ============================================================================ -/// A logical observable definition. +/// Metadata for a non-detector output definition. /// -/// Logical observables track the parity of certain measurement outcomes -/// to detect logical errors. +/// Observables are rendered as standard `L` targets. Tracked Paulis +/// use the same metadata shape but live in a separate PECOS-only ID space and +/// are never rendered as `L` because they are unmeasured Pauli-operator +/// annotations, not measurement-record observables. #[derive(Debug, Clone)] -pub struct LogicalObservable { - /// Unique observable ID. +pub struct DemOutput { + /// Unique ID within this output's ID space. pub id: u32, - /// Measurement record offsets (negative indices from end of record). + /// Measurement record offsets (negative indices from end of record), when + /// this output is tied to measurement records. pub records: SmallVec<[i32; 4]>, + /// PECOS DEM output kind, when known. + pub kind: Option, + /// Pauli string whose flip is tracked, when this came from a Pauli + /// annotation or logical operator builder. + pub pauli: Option, + /// Optional user label. + pub label: Option, } -impl LogicalObservable { - /// Creates a new logical observable. +impl DemOutput { + /// Creates a new unclassified DEM output. #[must_use] pub fn new(id: u32) -> Self { Self { id, records: SmallVec::new(), + kind: None, + pauli: None, + label: None, + } + } + + /// Creates a DEM output from PECOS propagation metadata. + #[must_use] + pub fn from_metadata(id: u32, metadata: &DemOutputMetadata) -> Self { + Self { + id, + records: SmallVec::new(), + kind: Some(metadata.kind), + pauli: Some(metadata.pauli.clone()), + label: metadata.label.clone(), } } /// Sets the measurement records. #[must_use] pub fn with_records(mut self, records: impl IntoIterator) -> Self { - self.records = records.into_iter().collect(); + self.records.clear(); + for record in records { + toggle_dem_output_record(&mut self.records, record); + } + self.kind.get_or_insert(DemOutputKind::Observable); + self + } + + /// Sets the DEM output kind. + #[must_use] + pub fn with_kind(mut self, kind: DemOutputKind) -> Self { + self.kind = Some(kind); + self + } + + /// Sets the tracked Pauli string. + #[must_use] + pub fn with_pauli(mut self, mut pauli: PauliString) -> Self { + // A DEM output flip is an anticommutation property; global phase + // has no meaning for DEM/sampler output. + pauli.set_phase(pecos_core::QuarterPhase::PlusOne); + self.pauli = Some(pauli); + self + } + + /// Sets a user-facing label. + #[must_use] + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); self } + + /// Returns true when this DEM output is an observable. + #[must_use] + pub fn is_observable(&self) -> bool { + match self.kind { + Some(DemOutputKind::Observable) => true, + Some(DemOutputKind::TrackedPauli) => false, + None => !self.records.is_empty(), + } + } + + /// Returns true when this DEM output is a tracked Pauli. + #[must_use] + pub fn is_tracked_pauli(&self) -> bool { + match self.kind { + Some(DemOutputKind::TrackedPauli) => true, + Some(DemOutputKind::Observable) => false, + None => self.pauli.is_some() && self.records.is_empty(), + } + } +} + +fn merge_record_parity(existing: &mut SmallVec<[i32; 4]>, incoming: SmallVec<[i32; 4]>) { + for record in incoming { + toggle_dem_output_record(existing, record); + } +} + +fn toggle_dem_output_record(records: &mut SmallVec<[i32; 4]>, record: i32) { + if let Some(pos) = records + .iter() + .position(|&existing_record| existing_record == record) + { + records.remove(pos); + } else { + records.push(record); + } +} + +/// Error returned when parsing or applying PECOS DEM metadata JSON. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct PecosDemMetadataError { + message: String, +} + +impl PecosDemMetadataError { + fn new(message: impl Into) -> Self { + Self { + message: message.into(), + } + } + + /// Human-readable parse/apply error. + #[must_use] + pub fn message(&self) -> &str { + &self.message + } +} + +impl fmt::Display for PecosDemMetadataError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "{}", self.message) + } } +impl std::error::Error for PecosDemMetadataError {} + // ============================================================================ // Noise Configuration // ============================================================================ -/// Noise model configuration for DEM generation. -#[derive(Debug, Clone, Copy)] +/// Per-Pauli fault probability weights. +/// +/// Maps `PauliString` to relative probability. Entries must sum to ~1.0. +/// Used to customize the fault distribution for single-qubit or two-qubit gates. +/// +/// # Examples +/// +/// ``` +/// use pecos_core::pauli::{X, Y, Z}; +/// use pecos_qec::fault_tolerance::dem_builder::PauliWeights; +/// +/// // Single-qubit: biased toward dephasing +/// let w = PauliWeights::from([(Z(0), 0.8), (X(0), 0.1), (Y(0), 0.1)]); +/// assert_eq!(w.entries().len(), 3); +/// +/// // Two-qubit: uniform (convenience) +/// let w = PauliWeights::uniform_2q(); +/// assert_eq!(w.entries().len(), 15); +/// ``` +#[derive(Debug, Clone)] +pub struct PauliWeights { + /// (`PauliString`, weight) pairs. Weights must sum to ~1.0. + entries: Vec<(pecos_core::PauliString, f64)>, +} + +impl PauliWeights { + /// Create from an iterator of `(PauliString, weight)` pairs. + /// + /// Validates that weights sum to approximately 1.0 (within 1e-6). + /// + /// # Panics + /// + /// Panics if weights don't sum to ~1.0 or if any weight is negative. + pub fn new(entries: impl IntoIterator) -> Self { + let entries: Vec<_> = entries.into_iter().collect(); + let sum: f64 = 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 { + assert!(*w >= 0.0, "Weight for {ps} must be non-negative, got {w}"); + } + Self { entries } + } + + /// Uniform weights for single-qubit gates: X, Y, Z each with 1/3. + #[must_use] + pub fn uniform_1q() -> Self { + 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)], + } + } + + /// Uniform weights for two-qubit gates: all 15 non-identity Paulis at 1/15. + #[must_use] + pub fn uniform_2q() -> Self { + use pecos_core::pauli::{X, Y, Z}; + let w = 1.0 / 15.0; + Self { + entries: vec![ + (X(1), w), + (Y(1), w), + (Z(1), w), + (X(0), w), + (X(0) & X(1), w), + (X(0) & Y(1), w), + (X(0) & Z(1), w), + (Y(0), w), + (Y(0) & X(1), w), + (Y(0) & Y(1), w), + (Y(0) & Z(1), w), + (Z(0), w), + (Z(0) & X(1), w), + (Z(0) & Y(1), w), + (Z(0) & Z(1), w), + ], + } + } + + /// Look up the weight for a specific `PauliString`. + /// + /// Matches by Pauli type pattern only, ignoring qubit IDs. + /// E.g., `X(3) & Z(7)` matches a weight entry `X(0) & Z(1)` because + /// both have the pattern [X, Z] (sorted by qubit position). + #[must_use] + pub fn weight_for(&self, pauli: &pecos_core::PauliString) -> f64 { + let query_pattern = pauli_pattern(pauli); + self.entries + .iter() + .find(|(ps, _)| pauli_pattern(ps) == query_pattern) + .map_or(0.0, |(_, w)| *w) + } + + /// Get all entries as `(PauliString, weight)` pairs. + #[must_use] + pub fn entries(&self) -> &[(pecos_core::PauliString, f64)] { + &self.entries + } +} + +impl From<[(pecos_core::PauliString, f64); N]> for PauliWeights { + fn from(entries: [(pecos_core::PauliString, f64); N]) -> Self { + Self::new(entries) + } +} + +/// Noise model configuration for circuit-level fault analysis. +#[derive(Debug, Clone)] pub struct NoiseConfig { - /// Single-qubit depolarizing error rate. + /// Single-qubit gate error rate. pub p1: f64, - /// Two-qubit depolarizing error rate. + /// Two-qubit gate error rate. pub p2: f64, /// Measurement error rate. pub p_meas: f64, /// Initialization (prep) error rate. - pub p_init: f64, + pub p_prep: f64, + /// Idle gate error rate per time unit. + /// + /// The actual error probability for an idle gate is `p_idle * duration` + /// (clamped to [0, 1]), where `duration` is the gate's `TimeUnits` value. + /// Default is 0.0 (no idle noise). + pub p_idle: f64, + /// Optional T1 relaxation time (in the same time units as idle duration). + /// + /// When set (along with T2), idle noise uses the Pauli-twirled + /// amplitude damping + dephasing model instead of uniform depolarizing. + /// This gives biased noise: P(Z) > P(X) = P(Y). + pub t1: Option, + /// Optional T2 dephasing time (must satisfy T2 <= 2*T1). + pub t2: Option, + /// Optional per-Pauli weights for single-qubit gates. + /// + /// Maps each Pauli fault to its relative probability. Must sum to ~1.0. + /// Default (None) = uniform depolarizing. + pub p1_weights: Option, + /// Optional per-Pauli weights for two-qubit gates. + /// + /// Maps each two-qubit Pauli fault to its relative probability. Must sum to ~1.0. + /// Default (None) = uniform depolarizing. + pub p2_weights: Option, + /// Coherent idle RZ rotation angle per time unit. + /// + /// When set (> 0), idle gates contribute a coherent Z rotation in addition + /// to any stochastic idle noise. Idle fault locations with the same + /// detector set have their angles accumulated coherently (angles add), + /// giving probability `sin²(total_angle/2)` instead of independent combination. + /// + /// This is the EEG H-type noise model for idle gates. Default is 0.0. + pub idle_rz: f64, +} + +/// Per-Pauli error probabilities for a single qubit. +#[derive(Debug, Clone, Copy)] +pub struct PauliProbs { + /// Probability of X error. + pub px: f64, + /// Probability of Y error. + pub py: f64, + /// Probability of Z error. + pub pz: f64, +} + +impl PauliProbs { + /// Total error probability (px + py + pz). + #[must_use] + pub fn total(&self) -> f64 { + self.px + self.py + self.pz + } + + /// Uniform depolarizing: each Pauli with probability p/3. + #[must_use] + pub fn depolarizing(p: f64) -> Self { + Self { + px: p / 3.0, + py: p / 3.0, + pz: p / 3.0, + } + } + + /// Pauli-twirled T1/T2 noise for idle duration t. + /// + /// Approximates the combined amplitude damping (T1) and pure + /// dephasing (T2) channel as a Pauli channel via Pauli twirling: + /// + /// P(X) = P(Y) = (1 - e^{-t/T1}) / 4 + /// P(Z) = (1 - e^{-t/T2}) / 2 - (1 - e^{-t/T1}) / 4 + /// + /// Requires T2 <= 2*T1 (physical constraint). + #[must_use] + pub fn from_t1_t2(t: f64, t1: f64, t2: f64) -> Self { + let gamma = 1.0 - (-t / t1).exp(); // amplitude damping parameter + let lambda_t2 = 1.0 - (-t / t2).exp(); // total dephasing parameter + + let px = gamma / 4.0; + let py = gamma / 4.0; + let pz = (lambda_t2 / 2.0 - gamma / 4.0).max(0.0); + + Self { px, py, pz } + } } impl Default for NoiseConfig { @@ -1205,127 +1599,1295 @@ impl Default for NoiseConfig { p1: 0.01, p2: 0.01, p_meas: 0.01, - p_init: 0.01, + p_prep: 0.01, + p_idle: 0.0, + t1: None, + t2: None, + p1_weights: None, + p2_weights: None, + idle_rz: 0.0, } } } impl NoiseConfig { - /// Creates a new noise configuration. + /// Creates a new noise configuration (idle defaults to `None`). + #[must_use] + pub fn new(p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> Self { + Self { + p1, + p2, + p_meas, + p_prep, + p_idle: 0.0, + t1: None, + t2: None, + p1_weights: None, + p2_weights: None, + idle_rz: 0.0, + } + } + + /// Creates a new noise configuration with uniform depolarizing idle noise. #[must_use] - pub fn new(p1: f64, p2: f64, p_meas: f64, p_init: f64) -> Self { + pub fn with_idle(p1: f64, p2: f64, p_meas: f64, p_prep: f64, p_idle: f64) -> Self { Self { p1, p2, p_meas, - p_init, + p_prep, + p_idle, + t1: None, + t2: None, + p1_weights: None, + p2_weights: None, + idle_rz: 0.0, } } - /// Creates a uniform noise configuration. + /// Creates a uniform noise configuration (including depolarizing idle). #[must_use] pub fn uniform(p: f64) -> Self { Self { p1: p, p2: p, p_meas: p, - p_init: p, + p_prep: p, + p_idle: p, + t1: None, + t2: None, + p1_weights: None, + p2_weights: None, + idle_rz: 0.0, } } -} - -// ============================================================================ -// Detector Error Model -// ============================================================================ - -/// A complete Detector Error Model (DEM). -/// -/// This represents the noise model of a quantum circuit. It maps mechanisms -/// (detector/logical effects) to their probabilities. -/// -/// # Aggregation Modes -/// -/// The DEM supports two modes controlled by `aggregate`: -/// -/// - **Aggregated mode** (`aggregate = true`): Mechanisms with the same effect -/// are combined into a single entry using the independent error formula. -/// This is more compact but loses information about noise sources. -/// -/// - **Non-aggregated mode** (`aggregate = false`, default): Each noise source -/// is kept as a separate entry. This preserves correlation information and -/// allows advanced decoders to understand the noise structure. -/// -/// # Decomposed Entries -/// -/// In addition to direct mechanisms, the DEM can store "decomposed" entries -/// that represent Y faults as X^Z. These are stored separately because: -/// -/// 1. They help MWPM decoders understand correlation structure -/// 2. They preserve information about fault sources -/// -/// When a Y fault has X-effect {`D_x`} and Z-effect {`D_z`} where both are non-empty -/// and different, it can be represented as `{D_x} ^ {D_z}` instead of XOR-ing -/// into a single mechanism. -#[derive(Debug, Clone)] -pub struct DetectorErrorModel { - /// Detector definitions. - pub detectors: Vec, - /// Logical observable definitions. - pub observables: Vec, - /// Error contributions with source tracking. - /// Each contribution tracks whether it came from a direct (X, Z) or decomposable (Y) source. - contributions: Vec, - /// Count of graphlike decomposable sources per 2-detector mechanism. - /// Key is (d0, d1) with d0 < d1. A source is "graphlike decomposable" if both - /// component effects are non-empty and graphlike (≤2 detectors). - /// Used to determine output format: ≥2 → 3 forms, 1 → 2 forms, 0 → 1 form. - graphlike_decomposable_counts: BTreeMap<(u32, u32), u32>, -} -impl DetectorErrorModel { - /// Creates a new empty DEM. + /// Sets the idle noise rate on an existing config (uniform depolarizing). #[must_use] - pub fn new() -> Self { - Self { - detectors: Vec::new(), - observables: Vec::new(), - contributions: Vec::new(), - graphlike_decomposable_counts: BTreeMap::new(), - } + pub fn set_idle(mut self, p_idle: f64) -> Self { + self.p_idle = p_idle; + self } - /// Creates a DEM with pre-allocated capacity. + /// Sets T1/T2 relaxation times for idle noise. + /// + /// When set, idle gates use the Pauli-twirled T1/T2 model instead of + /// uniform depolarizing. This produces biased noise where Z errors + /// (dephasing) are more likely than X/Y errors (relaxation). + /// + /// T1 and T2 are in the same time units as idle gate durations. + /// Must satisfy T2 <= 2*T1 (physical constraint). + /// + /// # Panics + /// + /// Panics if `t2 > 2 * t1`, which violates the physical constraint + /// that the dephasing time cannot exceed twice the relaxation time. #[must_use] - pub fn with_capacity(num_detectors: usize, num_observables: usize) -> Self { - Self { - detectors: Vec::with_capacity(num_detectors), - observables: Vec::with_capacity(num_observables), - contributions: Vec::new(), - graphlike_decomposable_counts: BTreeMap::new(), - } + pub fn set_t1_t2(mut self, t1: f64, t2: f64) -> Self { + assert!( + t2 <= 2.0 * t1, + "T2 ({t2}) must be <= 2*T1 ({}) (physical constraint)", + 2.0 * t1 + ); + self.t1 = Some(t1); + self.t2 = Some(t2); + self } - /// Returns the number of detectors. - #[inline] + /// Sets custom per-Pauli weights for single-qubit gates. + /// + /// ``` + /// use pecos_core::pauli::{X, Y, Z}; + /// use pecos_qec::fault_tolerance::dem_builder::{NoiseConfig, PauliWeights}; + /// + /// let noise = NoiseConfig::uniform(0.001).set_p1_weights(PauliWeights::from([ + /// (X(0), 0.1), (Y(0), 0.1), (Z(0), 0.8), + /// ])); + /// assert_eq!(noise.p1_weights.as_ref().unwrap().weight_for(&Z(7)), 0.8); + /// ``` #[must_use] - pub fn num_detectors(&self) -> usize { - self.detectors.len() + pub fn set_p1_weights(mut self, weights: PauliWeights) -> Self { + self.p1_weights = Some(weights); + self } - /// Returns the number of observables. - #[inline] + /// Sets custom per-Pauli weights for two-qubit gates. #[must_use] - pub fn num_observables(&self) -> usize { - self.observables.len() + pub fn set_p2_weights(mut self, weights: PauliWeights) -> Self { + self.p2_weights = Some(weights); + self } - /// Returns the number of tracked contributions. - #[inline] - #[must_use] + /// Sets idle noise from a coherent RZ rotation angle per time unit. + /// + /// Converts `idle_rz` (the angle theta of an RZ(theta) rotation applied + /// per idle time unit) to an equivalent stochastic Z-biased noise: + /// + /// P(Z) = sin^2(theta/2) per idle time unit + /// P(X) = P(Y) = 0 + /// + /// This is the exact Pauli twirling of a pure dephasing channel and + /// gives the non-EEG DEM builder correct idle noise behavior including + /// proper correlations through the fault influence map. + #[must_use] + pub fn set_idle_rz(mut self, idle_rz: f64) -> Self { + self.idle_rz = idle_rz; + let pz = (idle_rz / 2.0).sin().powi(2); + // Use T1/T2 model: T1=infinity (no amplitude damping), T2 chosen to match pz. + // From the T1/T2 model: pz = (1 - exp(-t/T2))/2 for T1=inf, t=1. + // Solve: T2 = -1/ln(1 - 2*pz) + // This provides a stochastic representation for non-coherent code paths. + if pz > 0.0 && pz < 0.5 { + let t2 = -1.0 / (1.0 - 2.0 * pz).ln(); + let t1 = 1e15; // effectively infinity + self.t1 = Some(t1); + self.t2 = Some(t2); + } + self.p_idle = 0.0; + self + } + + /// 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) { + PauliProbs::from_t1_t2(duration, t1, t2) + } else { + PauliProbs::depolarizing((self.p_idle * duration).min(1.0)) + } + } + + /// Returns true when idle locations use the dedicated idle-noise model. + /// + /// 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(_))) + } +} + +/// Extract the Pauli type pattern from a `PauliString`, ignoring qubit IDs. +/// +/// Returns the sequence of Pauli types sorted by qubit position. +/// E.g., X(3) & Z(7) -> [X, Z], same as X(0) & Z(1) -> [X, Z]. +fn pauli_pattern(ps: &pecos_core::PauliString) -> Vec { + ps.paulis().iter().map(|&(p, _)| p).collect() +} + +fn pecos_metadata_dem_output_value(target: &DemOutput) -> serde_json::Value { + serde_json::json!({ + "id": target.id, + "kind": target.kind.map_or("dem_output", DemOutputKind::as_str), + "label": target.label, + "pauli": target.pauli.as_ref().map(PauliString::to_sparse_str), + "records": target.records.iter().copied().collect::>(), + }) +} + +#[derive(Debug, Clone, Default)] +struct ParsedPecosDemMetadata { + observables: Vec, + tracked_paulis: Vec, +} + +pub(crate) fn parse_pecos_dem_metadata_line( + line: &str, +) -> Result { + let line = line.trim(); + let (prefix, payload, forced_kind) = + if let Some(payload) = line.strip_prefix("pecos_tracked_pauli") { + ( + "pecos_tracked_pauli", + payload.trim(), + Some(DemOutputKind::TrackedPauli), + ) + } else if let Some(payload) = line.strip_prefix("pecos_observable") { + ( + "pecos_observable", + payload.trim(), + Some(DemOutputKind::Observable), + ) + } else { + return Err(PecosDemMetadataError::new( + "missing PECOS DEM metadata prefix", + )); + }; + if payload.is_empty() { + return Err(PecosDemMetadataError::new(format!( + "{prefix} is missing its JSON payload" + ))); + } + + let value: serde_json::Value = serde_json::from_str(payload).map_err(|err| { + PecosDemMetadataError::new(format!("invalid {prefix} JSON payload: {err}")) + })?; + let mut output = parse_pecos_metadata_dem_output(0, &value)?; + if let Some(kind) = forced_kind { + output.kind = Some(kind); + } + if output.is_tracked_pauli() && !output.records.is_empty() { + return Err(PecosDemMetadataError::new( + "tracked Pauli metadata cannot have measurement records", + )); + } + Ok(output) +} + +fn parse_pecos_metadata_json(json: &str) -> Result { + let root: serde_json::Value = serde_json::from_str(json).map_err(|err| { + PecosDemMetadataError::new(format!("invalid PECOS DEM metadata JSON: {err}")) + })?; + + let format = root + .get("format") + .and_then(serde_json::Value::as_str) + .ok_or_else(|| PecosDemMetadataError::new("missing metadata format"))?; + if format != "pecos.dem.metadata" { + return Err(PecosDemMetadataError::new(format!( + "unsupported metadata format: {format}" + ))); + } + + let version = root + .get("version") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| PecosDemMetadataError::new("missing metadata version"))?; + if version != 1 { + return Err(PecosDemMetadataError::new(format!( + "unsupported PECOS DEM metadata version: {version}" + ))); + } + + for old_name in ["tracked_ops", "tracked_operators", "pauli_operators"] { + if root.get(old_name).is_some() { + return Err(PecosDemMetadataError::new(format!( + "unsupported legacy metadata field: {old_name}; use tracked_paulis" + ))); + } + } + + let parse_array = + |name: &str, kind: DemOutputKind| -> Result, PecosDemMetadataError> { + let Some(values) = root.get(name) else { + return Ok(Vec::new()); + }; + let values = values.as_array().ok_or_else(|| { + PecosDemMetadataError::new(format!("{name} metadata is not an array")) + })?; + values + .iter() + .enumerate() + .map(|(idx, value)| { + let mut output = parse_pecos_metadata_dem_output(idx, value)?; + output.kind = Some(kind); + if kind == DemOutputKind::TrackedPauli && !output.records.is_empty() { + return Err(PecosDemMetadataError::new(format!( + "tracked Pauli metadata {idx} cannot have measurement records" + ))); + } + Ok(output) + }) + .collect() + }; + + let parsed = ParsedPecosDemMetadata { + observables: parse_array("observables", DemOutputKind::Observable)?, + tracked_paulis: parse_array("tracked_paulis", DemOutputKind::TrackedPauli)?, + }; + + if root.get("observables").is_none() && root.get("tracked_paulis").is_none() { + return Err(PecosDemMetadataError::new( + "missing observables or tracked_paulis metadata arrays", + )); + } + + Ok(parsed) +} + +fn parse_pecos_metadata_dem_output( + idx: usize, + target: &serde_json::Value, +) -> Result { + let object = target + .as_object() + .ok_or_else(|| PecosDemMetadataError::new(format!("DEM output {idx} is not an object")))?; + + let id = object + .get("id") + .and_then(serde_json::Value::as_u64) + .ok_or_else(|| PecosDemMetadataError::new(format!("DEM output {idx} is missing id")))?; + let id = u32::try_from(id).map_err(|_| { + PecosDemMetadataError::new(format!("DEM output {idx} id does not fit in u32")) + })?; + + let mut dem_output = DemOutput::new(id); + let mut explicit_kind = None; + + if let Some(kind_value) = object.get("kind") { + let kind = kind_value.as_str().ok_or_else(|| { + PecosDemMetadataError::new(format!("DEM output {idx} kind is not a string")) + })?; + if kind != "dem_output" { + let parsed_kind = DemOutputKind::from_metadata_str(kind).ok_or_else(|| { + PecosDemMetadataError::new(format!("DEM output {idx} has unknown kind: {kind}")) + })?; + explicit_kind = Some(parsed_kind); + dem_output = dem_output.with_kind(parsed_kind); + } + } + + if let Some(label_value) = object.get("label") + && !label_value.is_null() + { + let label = label_value.as_str().ok_or_else(|| { + PecosDemMetadataError::new(format!("DEM output {idx} label is not a string or null")) + })?; + dem_output = dem_output.with_label(label); + } + + if let Some(pauli_value) = object.get("pauli") + && !pauli_value.is_null() + { + let pauli = pauli_value.as_str().ok_or_else(|| { + PecosDemMetadataError::new(format!("DEM output {idx} pauli is not a string or null")) + })?; + let pauli = pauli.parse::().map_err(|err| { + PecosDemMetadataError::new(format!("DEM output {idx} has invalid PauliString: {err}")) + })?; + dem_output = dem_output.with_pauli(pauli); + } + + let records = if let Some(records_value) = object.get("records") { + if records_value.is_null() { + Vec::new() + } else { + let records = records_value.as_array().ok_or_else(|| { + PecosDemMetadataError::new(format!( + "DEM output {idx} records is not an array or null" + )) + })?; + records + .iter() + .enumerate() + .map(|(record_idx, record)| { + let record = record.as_i64().ok_or_else(|| { + PecosDemMetadataError::new(format!( + "DEM output {idx} record {record_idx} is not an integer" + )) + })?; + i32::try_from(record).map_err(|_| { + PecosDemMetadataError::new(format!( + "DEM output {idx} record {record_idx} does not fit in i32" + )) + }) + }) + .collect::, _>>()? + } + } else { + Vec::new() + }; + + if explicit_kind == Some(DemOutputKind::TrackedPauli) && !records.is_empty() { + return Err(PecosDemMetadataError::new(format!( + "tracked Pauli DEM output {idx} cannot have measurement records" + ))); + } + + if !records.is_empty() || explicit_kind == Some(DemOutputKind::Observable) { + dem_output = dem_output.with_records(records); + } + + Ok(dem_output) +} + +// ============================================================================ +// Per-gate-type noise configuration +// ============================================================================ + +use pecos_core::QubitId; +use std::collections::HashMap; + +/// Ordered indices for the 3 non-identity 1Q Paulis: `[X, Y, Z]`. +pub const PAULI_1Q_ORDER: [&str; 3] = ["X", "Y", "Z"]; + +/// Ordered indices for the 15 non-identity 2Q Pauli pairs. Row/col order: +/// `I=0, X=1, Y=2, Z=3`; pair `p1 ⊗ p2` index is `4*p1 + p2 - 1` (skip II). +/// Concretely: `["IX", "IY", "IZ", "XI", "XX", "XY", "XZ", "YI", "YX", +/// "YY", "YZ", "ZI", "ZX", "ZY", "ZZ"]`. +pub const PAULI_2Q_ORDER: [&str; 15] = [ + "IX", "IY", "IZ", "XI", "XX", "XY", "XZ", "YI", "YX", "YY", "YZ", "ZI", "ZX", "ZY", "ZZ", +]; + +/// Per-gate-type, optionally per-qubit noise specification. Replaces the +/// uniform scalar `NoiseConfig` with per-`GateType` per-Pauli rates, with +/// an optional per-qubit override layer for devices where `T_1`/`T_2` +/// varies qubit-to-qubit. +/// +/// # Layered lookup +/// +/// Rate resolution uses the most specific configured entry: +/// +/// ```text +/// 1. rates_1q_per_qubit[(gate, qubit)] // most specific +/// 2. rates_1q[gate] // per-gate-type default +/// 3. base.p1 / 3.0 // base noise model +/// ``` +/// +/// (And analogously for 2Q with `(gate, (q_control, q_target))`.) +/// +/// This lets users specify "H on qubit 0 has these rates (high `T_1`), H on +/// qubit 1 has these (low `T_1`), every other H uses the per-gate default". +/// +/// # Integration with `pecos-lindblad` +/// +/// The intended workflow is: +/// 1. Synthesize a `PauliLindbladModel` for each gate type *and* per +/// qubit if needed via `pecos_lindblad::synthesize_superop(...)`. +/// 2. Convert to `[f64; 3]` / `[f64; 15]` arrays. +/// 3. Register with [`Self::with_1q_rates_for_qubit`] / +/// [`Self::with_2q_rates_for_qubits`] for heterogeneous devices, or +/// [`Self::with_1q_rates`] / [`Self::with_2q_rates`] for homogeneous +/// models. +#[derive(Debug, Clone, Default)] +pub struct PerGateTypeNoise { + pub rates_1q: HashMap, + pub rates_2q: HashMap, + pub rates_1q_per_qubit: HashMap<(GateType, QubitId), [f64; 3]>, + pub rates_2q_per_qubits: HashMap<(GateType, QubitId, QubitId), [f64; 15]>, + /// Per-qubit readout (MZ) X-flip probabilities. Qubits not in this map use + /// [`Self::p_meas`]. + pub measurement_rates: HashMap, + /// Per-qubit preparation (PZ) X-error probabilities. Qubits not in this map + /// use [`Self::p_init`]. + pub init_rates: HashMap, + pub p_meas: f64, + pub p_init: f64, + pub base: NoiseConfig, +} + +impl PerGateTypeNoise { + /// Construct with empty gate maps; unspecified gates use `base`. + #[must_use] + pub fn from_base_noise(base: NoiseConfig) -> Self { + Self { + rates_1q: HashMap::new(), + rates_2q: HashMap::new(), + rates_1q_per_qubit: HashMap::new(), + rates_2q_per_qubits: HashMap::new(), + measurement_rates: HashMap::new(), + init_rates: HashMap::new(), + p_meas: base.p_meas, + p_init: base.p_prep, + base, + } + } + + /// Attach measurement X-flip probability for a specific qubit. + /// Overrides [`Self::p_meas`] when set. Use for devices with + /// heterogeneous readout fidelity. + #[must_use] + pub fn with_measurement_rate(mut self, q: QubitId, p: f64) -> Self { + self.measurement_rates.insert(q, p); + self + } + + /// Attach preparation X-error probability for a specific qubit. + /// Overrides [`Self::p_init`] when set. + #[must_use] + pub fn with_init_rate(mut self, q: QubitId, p: f64) -> Self { + self.init_rates.insert(q, p); + self + } + + /// Lookup measurement X-flip rate for a qubit. Unspecified qubits use + /// [`Self::p_meas`], which is seeded from the base `NoiseConfig::p_meas`. + #[must_use] + pub fn measurement_rate_on(&self, q: QubitId) -> f64 { + *self.measurement_rates.get(&q).unwrap_or(&self.p_meas) + } + + /// Lookup preparation X-error rate for a qubit. Unspecified qubits use + /// [`Self::p_init`]. + #[must_use] + pub fn init_rate_on(&self, q: QubitId) -> f64 { + *self.init_rates.get(&q).unwrap_or(&self.p_init) + } + + /// Attach rates for a 1Q gate type applied to any qubit. + #[must_use] + pub fn with_1q_rates(mut self, g: GateType, rates: [f64; 3]) -> Self { + self.rates_1q.insert(g, rates); + self + } + + /// Attach rates for a 2Q gate type applied to any qubit pair. + #[must_use] + pub fn with_2q_rates(mut self, g: GateType, rates: [f64; 15]) -> Self { + self.rates_2q.insert(g, rates); + self + } + + /// Attach rates for a 1Q gate on a specific qubit. Takes precedence + /// over [`Self::with_1q_rates`] for that `(gate, qubit)` combination. + #[must_use] + pub fn with_1q_rates_for_qubit(mut self, g: GateType, q: QubitId, rates: [f64; 3]) -> Self { + self.rates_1q_per_qubit.insert((g, q), rates); + self + } + + /// Return explicitly attached 1Q Pauli rates for a gate type. + #[must_use] + pub fn explicit_1q_rates(&self, gate: GateType) -> Option<[f64; 3]> { + self.rates_1q.get(&gate).copied() + } + + /// Return explicitly attached 1Q Pauli rates for a gate on a specific qubit. + /// + /// Per-qubit rates take precedence over gate-type rates. Unlike + /// [`Self::rate_1q_on`], this does not fall back to the base noise model. + #[must_use] + pub fn explicit_1q_rates_on(&self, gate: GateType, qubit: QubitId) -> Option<[f64; 3]> { + self.rates_1q_per_qubit + .get(&(gate, qubit)) + .copied() + .or_else(|| self.explicit_1q_rates(gate)) + } + + /// Attach rates for a 2Q gate on a specific ordered qubit pair. + /// Takes precedence over [`Self::with_2q_rates`] for that + /// `(gate, q_control, q_target)` combination. + #[must_use] + pub fn with_2q_rates_for_qubits( + mut self, + g: GateType, + q_control: QubitId, + q_target: QubitId, + rates: [f64; 15], + ) -> Self { + self.rates_2q_per_qubits + .insert((g, q_control, q_target), rates); + 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. + /// + /// `Idle` is a no-op by default. It receives noise only from explicitly + /// attached idle rates or from the base idle-noise model. + #[must_use] + pub fn rate_1q(&self, gate: GateType, pauli_idx: usize) -> f64 { + if let Some(rates) = self.rates_1q.get(&gate) { + return rates[pauli_idx]; + } + if gate == GateType::Idle { + if self.base.uses_dedicated_idle_noise() { + let probs = self.base.idle_pauli_probs(1.0); + return match pauli_idx { + 0 => probs.px, + 1 => probs.py, + 2 => probs.pz, + _ => 0.0, + }; + } + return 0.0; + } + self.base.p1 / 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`. + /// `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 { + if let Some(rates) = self.rates_1q_per_qubit.get(&(gate, qubit)) { + return rates[pauli_idx]; + } + 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`]. + #[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]) + } + + /// 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`. + #[must_use] + pub fn rate_2q_on( + &self, + gate: GateType, + q_control: QubitId, + q_target: QubitId, + pair_idx: usize, + ) -> f64 { + if let Some(rates) = self.rates_2q_per_qubits.get(&(gate, q_control, q_target)) { + return rates[pair_idx]; + } + self.rate_2q(gate, pair_idx) + } +} + +// ============================================================================ +// Measurement Noise Model (MNM) +// ============================================================================ + +/// A measurement fault mechanism: a set of measurements that flip together. +/// +/// Unlike [`FaultMechanism`], this operates directly on raw measurement +/// indices. This is useful for sampling measurement outcomes without needing +/// detector definitions. +#[derive(Clone, Default)] +pub struct MeasurementMechanism { + /// Measurement indices that flip together, sorted canonically. + pub measurements: SmallVec<[u32; 4]>, +} + +impl MeasurementMechanism { + /// Creates a new empty measurement mechanism. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Creates a mechanism from unsorted measurement indices. + #[must_use] + pub fn from_unsorted(measurements: impl IntoIterator) -> Self { + let mut measurements: SmallVec<[u32; 4]> = measurements.into_iter().collect(); + measurements.sort_unstable(); + Self { measurements } + } + + /// Creates a mechanism from pre-sorted measurement indices. + #[must_use] + pub fn from_sorted(measurements: SmallVec<[u32; 4]>) -> Self { + debug_assert!( + measurements.windows(2).all(|w| w[0] <= w[1]), + "measurements must be sorted" + ); + Self { measurements } + } + + /// Returns true if this mechanism has no effect. + #[inline] + #[must_use] + pub fn is_empty(&self) -> bool { + self.measurements.is_empty() + } + + /// Returns the number of measurements in this mechanism. + #[inline] + #[must_use] + pub fn len(&self) -> usize { + self.measurements.len() + } +} + +impl PartialEq for MeasurementMechanism { + fn eq(&self, other: &Self) -> bool { + self.measurements == other.measurements + } +} + +impl Eq for MeasurementMechanism {} + +impl Hash for MeasurementMechanism { + fn hash(&self, state: &mut H) { + self.measurements.hash(state); + } +} + +impl PartialOrd for MeasurementMechanism { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +impl Ord for MeasurementMechanism { + fn cmp(&self, other: &Self) -> Ordering { + self.measurements.cmp(&other.measurements) + } +} + +impl fmt::Debug for MeasurementMechanism { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "MeasurementMechanism({:?})", + self.measurements.as_slice() + ) + } +} + +/// A measurement noise model for fast approximate raw-measurement sampling. +#[derive(Debug, Clone, Default)] +pub struct MeasurementNoiseModel { + /// Fault mechanisms mapped to their probabilities. + pub mechanisms: BTreeMap, + /// Total number of measurements in the circuit. + pub num_measurements: usize, + /// Optional mapping from influence-map index to original circuit order. + pub im_to_tc_order: Option>, +} + +impl MeasurementNoiseModel { + /// Creates a new empty measurement noise model. + #[must_use] + pub fn new(num_measurements: usize) -> Self { + Self { + mechanisms: BTreeMap::new(), + num_measurements, + im_to_tc_order: None, + } + } + + /// Sets the measurement order mapping from influence-map order to circuit order. + #[must_use] + pub fn with_measurement_order(mut self, im_to_tc: Vec) -> Self { + self.im_to_tc_order = Some(im_to_tc); + self + } + + /// Sets the measurement order mapping in place. + pub fn set_measurement_order(&mut self, im_to_tc: Vec) { + self.im_to_tc_order = Some(im_to_tc); + } + + /// Returns the number of distinct mechanisms. + #[inline] + #[must_use] + pub fn num_mechanisms(&self) -> usize { + self.mechanisms.len() + } + + /// Adds a mechanism with probability, combining with any existing identical mechanism. + pub fn add_mechanism(&mut self, mechanism: MeasurementMechanism, probability: f64) { + if mechanism.is_empty() || probability <= 0.0 { + return; + } + + self.mechanisms + .entry(mechanism) + .and_modify(|p| *p = combine_probabilities(*p, probability)) + .or_insert(probability); + } + + /// Samples measurement outcomes into a pre-sized buffer. + pub fn sample_into(&self, outcomes: &mut [bool], rng: &mut R) { + outcomes.fill(false); + + for (mechanism, &prob) in &self.mechanisms { + if rng.random_bool(prob.clamp(0.0, 1.0)) { + for &meas_idx in &mechanism.measurements { + if (meas_idx as usize) < outcomes.len() { + outcomes[meas_idx as usize] ^= true; + } + } + } + } + } + + /// Samples and returns measurement outcomes. + #[must_use] + pub fn sample(&self, rng: &mut R) -> Vec { + let mut outcomes = vec![false; self.num_measurements]; + self.sample_into(&mut outcomes, rng); + outcomes + } + + /// Iterates over all mechanisms and their probabilities. + pub fn iter(&self) -> impl Iterator { + self.mechanisms.iter() + } + + /// Computes detector events from raw measurement outcomes and detector records. + #[must_use] + pub fn compute_detection_events( + &self, + outcomes: &[bool], + detector_records: &[Vec], + ) -> Vec { + let tc_outcomes: Vec = if let Some(ref im_to_tc) = self.im_to_tc_order { + let mut reordered = vec![false; outcomes.len()]; + for (im_idx, &tc_idx) in im_to_tc.iter().enumerate() { + if im_idx < outcomes.len() && tc_idx < reordered.len() { + reordered[tc_idx] = outcomes[im_idx]; + } + } + reordered + } else { + outcomes.to_vec() + }; + + Self::to_detection_events_internal(&tc_outcomes, detector_records) + } + + fn to_detection_events_internal(outcomes: &[bool], detector_records: &[Vec]) -> Vec { + let num_measurements = outcomes.len(); + detector_records + .iter() + .map(|records| { + records.iter().fold(false, |fired, &offset| { + if let Some(abs_idx) = record_offset_to_absolute_index(num_measurements, offset) + && abs_idx < num_measurements + && outcomes[abs_idx] + { + return !fired; + } + fired + }) + }) + .collect() + } + + /// Static detector-event conversion without reordering. + #[must_use] + pub fn to_detection_events(outcomes: &[bool], detector_records: &[Vec]) -> Vec { + Self::to_detection_events_internal(outcomes, detector_records) + } + + /// Samples and converts to detection events in one step. + pub fn sample_with_detectors( + &self, + detector_records: &[Vec], + rng: &mut R, + ) -> (Vec, Vec) { + let outcomes = self.sample(rng); + let detection_events = self.compute_detection_events(&outcomes, detector_records); + (outcomes, detection_events) + } + + /// Computes observable flips from measurement outcomes. + #[must_use] + pub fn compute_observable_flips( + &self, + outcomes: &[bool], + observable_records: &[Vec], + ) -> Vec { + self.compute_detection_events(outcomes, observable_records) + } + + /// Samples detector events and observable flips in one step. + pub fn sample_for_decoding( + &self, + detector_records: &[Vec], + observable_records: &[Vec], + rng: &mut R, + ) -> (Vec, Vec) { + let outcomes = self.sample(rng); + let detection_events = self.compute_detection_events(&outcomes, detector_records); + let observable_flips = self.compute_detection_events(&outcomes, observable_records); + (detection_events, observable_flips) + } + + /// Batch samples detector events and observable flips. + pub fn sample_batch_for_decoding( + &self, + num_shots: usize, + detector_records: &[Vec], + observable_records: &[Vec], + rng: &mut R, + ) -> (Vec>, Vec>) { + let mut all_detection_events = Vec::with_capacity(num_shots); + let mut all_observable_flips = Vec::with_capacity(num_shots); + + for _ in 0..num_shots { + let (det_events, obs_flips) = + self.sample_for_decoding(detector_records, observable_records, rng); + all_detection_events.push(det_events); + all_observable_flips.push(obs_flips); + } + + (all_detection_events, all_observable_flips) + } +} + +// ============================================================================ +// Detector Error Model +// ============================================================================ + +/// A complete Detector Error Model (DEM). +/// +/// This represents the noise model of a quantum circuit. It maps mechanisms +/// (detector/DEM-output effects) to their probabilities. +/// +/// # Aggregation Modes +/// +/// The DEM supports two modes controlled by `aggregate`: +/// +/// - **Aggregated mode** (`aggregate = true`): Mechanisms with the same effect +/// are combined into a single entry using the independent error formula. +/// This is more compact but loses information about noise sources. +/// +/// - **Non-aggregated mode** (`aggregate = false`, default): Each noise source +/// is kept as a separate entry. This preserves correlation information and +/// allows advanced decoders to understand the noise structure. +/// +/// # Decomposed Entries +/// +/// In addition to direct mechanisms, the DEM can store "decomposed" entries +/// that represent Y faults as X^Z. These are stored separately because: +/// +/// 1. They help MWPM decoders understand correlation structure +/// 2. They preserve information about fault sources +/// +/// When a Y fault has X-effect {`D_x`} and Z-effect {`D_z`} where both are non-empty +/// and different, it can be represented as `{D_x} ^ {D_z}` instead of XOR-ing +/// into a single mechanism. +#[derive(Debug, Clone)] +pub struct DetectorErrorModel { + /// Detector definitions. + pub detectors: Vec, + /// Measurement-record observables rendered as standard `L` outputs. + pub observables: Vec, + /// PECOS tracked Paulis. + /// + /// These have their own ID space and are emitted only as PECOS metadata. + pub tracked_paulis: Vec, + /// Error contributions with source tracking. + /// Each contribution tracks whether it came from a direct (X, Z) or decomposable (Y) source. + contributions: Vec, + /// Count of graphlike decomposable sources per 2-detector mechanism. + /// Key is (d0, d1) with d0 < d1. A source is "graphlike decomposable" if both + /// component effects are non-empty and graphlike (≤2 detectors). + /// Used to determine output format: ≥2 → 3 forms, 1 → 2 forms, 0 → 1 form. + graphlike_decomposable_counts: BTreeMap<(u32, u32), u32>, +} + +/// Structured DEM mechanism tuple: `(probability, detector_ids, observable_ids)`. +pub type MechanismTuple = (f64, Vec, Vec); + +/// Detector-coordinate tuple: `(detector_id, coordinates)`. +pub type DetectorCoordinateTuple = (u32, Vec); + +impl DetectorErrorModel { + /// Creates a new empty DEM. + #[must_use] + pub fn new() -> Self { + Self { + detectors: Vec::new(), + observables: Vec::new(), + tracked_paulis: Vec::new(), + contributions: Vec::new(), + graphlike_decomposable_counts: BTreeMap::new(), + } + } + + /// Creates a DEM with pre-allocated capacity. + #[must_use] + pub fn with_capacity(num_detectors: usize, num_observables: usize) -> Self { + Self { + detectors: Vec::with_capacity(num_detectors), + observables: Vec::with_capacity(num_observables), + tracked_paulis: Vec::new(), + contributions: Vec::new(), + graphlike_decomposable_counts: BTreeMap::new(), + } + } + + /// Returns the number of detectors. + #[inline] + #[must_use] + pub fn num_detectors(&self) -> usize { + self.detectors.len() + } + + /// Returns the number of observables. + #[inline] + #[must_use] + pub fn num_observables(&self) -> usize { + self.observables + .iter() + .map(|op| op.id as usize + 1) + .max() + .unwrap_or(0) + } + + /// Returns the number of standard DEM `L` observable outputs. + /// + /// This is a DEM-output alias for [`Self::num_observables`]. It does + /// not include PECOS tracked Paulis. + #[inline] + #[must_use] + pub fn num_dem_outputs(&self) -> usize { + self.num_observables() + } + + /// Returns the number of tracked Paulis. + #[inline] + #[must_use] + pub fn num_tracked_paulis(&self) -> usize { + self.tracked_paulis + .iter() + .map(|op| op.id as usize + 1) + .max() + .unwrap_or(0) + } + + /// Returns standard DEM output definitions (`L` observables). + /// + /// This DEM-output accessor does not include PECOS tracked Paulis; + /// use [`Self::tracked_paulis`] for those. + #[inline] + #[must_use] + pub fn dem_outputs(&self) -> &[DemOutput] { + &self.observables + } + + /// Returns mutable standard DEM output definitions (`L` observables). + /// + /// This DEM-output accessor does not include PECOS tracked Paulis. + #[inline] + #[must_use] + pub fn dem_outputs_mut(&mut self) -> &mut [DemOutput] { + &mut self.observables + } + + /// Iterates over observables. + pub fn observables(&self) -> impl Iterator { + self.observables.iter() + } + + /// Returns all tracked Pauli definitions. + #[inline] + #[must_use] + pub fn tracked_paulis(&self) -> &[DemOutput] { + &self.tracked_paulis + } + + /// Iterates over tracked Paulis. + pub fn iter_tracked_paulis(&self) -> impl Iterator { + self.tracked_paulis.iter() + } + + /// Returns the number of tracked contributions. + #[inline] + #[must_use] pub fn num_contributions(&self) -> usize { self.contributions.len() } + /// Exports PECOS-only metadata that is not representable in standard DEM syntax. + /// + /// The standard DEM string remains decoder-compatible and uses ordinary + /// `logical_observable L` declarations. This JSON form preserves the + /// richer PECOS DEM-output information, including tracked Paulis. + /// + /// # Panics + /// + /// Panics only if serializing a JSON value constructed in this method fails. + #[must_use] + pub fn to_pecos_metadata_json(&self) -> String { + let observables: Vec = self + .observables + .iter() + .map(pecos_metadata_dem_output_value) + .collect(); + let tracked_paulis: Vec = self + .tracked_paulis + .iter() + .map(pecos_metadata_dem_output_value) + .collect(); + + serde_json::to_string_pretty(&serde_json::json!({ + "format": "pecos.dem.metadata", + "version": 1, + "observables": observables, + "tracked_paulis": tracked_paulis, + })) + .expect("serializing PECOS DEM metadata should not fail") + } + + /// Applies PECOS DEM metadata JSON to this model. + /// + /// This is the inverse of [`Self::to_pecos_metadata_json`] for DEM output + /// definitions. It updates existing outputs by `id` and adds any that + /// are present in the metadata but missing from the DEM. + /// + /// # Errors + /// + /// Returns an error if the JSON is malformed, uses an unsupported metadata + /// version, has an unknown op kind, or contains invalid Pauli/record + /// fields. + pub fn apply_pecos_metadata_json(&mut self, json: &str) -> Result<(), PecosDemMetadataError> { + let metadata = parse_pecos_metadata_json(json)?; + for observable in metadata.observables { + self.apply_observable_metadata(observable); + } + for tracked_pauli in metadata.tracked_paulis { + self.apply_tracked_pauli_metadata(tracked_pauli); + } + Ok(()) + } + + /// Returns a copy of this model with PECOS DEM metadata JSON applied. + /// + /// # Errors + /// + /// See [`Self::apply_pecos_metadata_json`]. + pub fn with_pecos_metadata_json(mut self, json: &str) -> Result { + self.apply_pecos_metadata_json(json)?; + Ok(self) + } + + /// Converts the DEM to PECOS DEM text. + /// + /// This format is a strict superset of standard DEM text. It uses `D` + /// detector targets and `L` measurement-defined observable targets as + /// usual, and adds PECOS-only `TP` tracked-Pauli targets for tracked + /// operator flips. Metadata follows as `pecos_observable {json}` and + /// `pecos_tracked_pauli {json}` statements. + /// + /// # Panics + /// + /// Panics only if serializing JSON values constructed in this method fails. + #[must_use] + pub fn to_pecos_string(&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.clone()) + .and_modify(|p| *p = combine_independent_probs(*p, contrib.probability)) + .or_insert(contrib.probability); + } + + for (effect, total_prob) in by_effect { + if effect.is_empty() || total_prob <= 0.0 { + continue; + } + + let targets = format_pecos_mechanism_targets(&effect); + if !targets.is_empty() { + lines.push(format!( + "error({}) {}", + format_probability(total_prob), + targets + )); + } + } + + let metadata_lines = self.pecos_metadata_lines(); + + if metadata_lines.is_empty() { + return lines.join("\n"); + } + lines.extend(metadata_lines); + lines.join("\n") + } + + fn pecos_metadata_lines(&self) -> Vec { + let observable_lines = self.observables.iter().map(|observable| { + let value = pecos_metadata_dem_output_value(observable); + let payload = serde_json::to_string(&value) + .expect("serializing PECOS observable metadata should not fail"); + format!("pecos_observable {payload}") + }); + let tracked_pauli_lines = self.tracked_paulis.iter().map(|tracked_pauli| { + let value = pecos_metadata_dem_output_value(tracked_pauli); + let payload = serde_json::to_string(&value) + .expect("serializing PECOS tracked-Pauli metadata should not fail"); + format!("pecos_tracked_pauli {payload}") + }); + observable_lines.chain(tracked_pauli_lines).collect() + } + + /// Applies PECOS metadata embedded in extended DEM text. + /// + /// Standard DEM lines are ignored by this method. PECOS extension lines + /// are parsed and merged into the observable/tracked-Pauli definitions. + /// + /// # Errors + /// + /// Returns an error if a PECOS metadata line is malformed. + pub fn apply_pecos_dem_metadata( + &mut self, + dem_text: &str, + ) -> Result<(), PecosDemMetadataError> { + for line in dem_text.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; + } + if line.starts_with("pecos_observable") || line.starts_with("pecos_tracked_pauli") { + self.apply_dem_output_metadata(parse_pecos_dem_metadata_line(line)?); + } else if line.starts_with("pecos_") { + return Err(PecosDemMetadataError::new(format!( + "unsupported PECOS DEM extension line: {line}" + ))); + } + } + Ok(()) + } + + /// Returns a copy of this model with PECOS metadata from extended DEM text + /// applied. + /// + /// # Errors + /// + /// See [`Self::apply_pecos_dem_metadata`]. + pub fn with_pecos_dem_metadata( + mut self, + dem_text: &str, + ) -> Result { + self.apply_pecos_dem_metadata(dem_text)?; + Ok(self) + } + + fn apply_dem_output_metadata(&mut self, target: DemOutput) { + if target.is_tracked_pauli() { + self.apply_tracked_pauli_metadata(target); + } else { + self.apply_observable_metadata(target); + } + } + + fn apply_observable_metadata(&mut self, mut target: DemOutput) { + target.kind.get_or_insert(DemOutputKind::Observable); + if let Some(existing) = self + .observables + .iter_mut() + .find(|existing| existing.id == target.id) + { + *existing = target; + } else { + self.add_observable(target); + } + } + + fn apply_tracked_pauli_metadata(&mut self, mut target: DemOutput) { + target.kind = Some(DemOutputKind::TrackedPauli); + if let Some(existing) = self + .tracked_paulis + .iter_mut() + .find(|existing| existing.id == target.id) + { + *existing = target; + } else { + self.add_tracked_pauli(target); + } + } + /// Returns debug info about contributions for a specific mechanism. /// /// Format: One line per contribution showing source type and probability. @@ -1335,7 +2897,7 @@ impl DetectorErrorModel { let mut lines = Vec::new(); for contrib in &self.contributions { - if contrib.effect.detectors == target_dets && contrib.effect.logicals.is_empty() { + if contrib.effect.detectors == target_dets && contrib.effect.dem_outputs.is_empty() { let source_type = match &contrib.source_type { FaultSourceType::Direct => "Direct".to_string(), FaultSourceType::DirectOneSidedComponent => { @@ -1343,27 +2905,29 @@ impl DetectorErrorModel { } FaultSourceType::YDecomposed { x_detectors, - x_logicals, + x_dem_outputs, z_detectors, - z_logicals, + z_dem_outputs, } => { let x_dets: Vec<_> = x_detectors.iter().map(|d| format!("D{d}")).collect(); let z_dets: Vec<_> = z_detectors.iter().map(|d| format!("D{d}")).collect(); - let x_logs: Vec<_> = x_logicals.iter().map(|l| format!("L{l}")).collect(); - let z_logs: Vec<_> = z_logicals.iter().map(|l| format!("L{l}")).collect(); + let x_outputs: Vec<_> = + x_dem_outputs.iter().map(|l| format!("L{l}")).collect(); + let z_outputs: Vec<_> = + z_dem_outputs.iter().map(|l| format!("L{l}")).collect(); format!( "YDecomposed(X=[{}{}], Z=[{}{}])", x_dets.join(" "), - if x_logs.is_empty() { + if x_outputs.is_empty() { String::new() } else { - format!(" {}", x_logs.join(" ")) + format!(" {}", x_outputs.join(" ")) }, z_dets.join(" "), - if z_logs.is_empty() { + if z_outputs.is_empty() { String::new() } else { - format!(" {}", z_logs.join(" ")) + format!(" {}", z_outputs.join(" ")) } ) } @@ -1387,15 +2951,15 @@ impl DetectorErrorModel { } } - /// Returns all contributions matching a full detector/logical effect. + /// Returns all contributions matching a full detector/DEM-output effect. #[must_use] pub fn contributions_for_effect( &self, detectors: &[u32], - logicals: &[u32], + dem_outputs: &[u32], ) -> Vec { let target = - FaultMechanism::from_unsorted(detectors.iter().copied(), logicals.iter().copied()); + FaultMechanism::from_unsorted(detectors.iter().copied(), dem_outputs.iter().copied()); self.contributions .iter() .filter(|contrib| contrib.effect == target) @@ -1419,7 +2983,7 @@ impl DetectorErrorModel { .collect(); let log_str: Vec<_> = contrib .effect - .logicals + .dem_outputs .iter() .map(|l| format!("L{l}")) .collect(); @@ -1491,7 +3055,7 @@ impl DetectorErrorModel { } for summary in by_effect.values_mut() { - if summary.effect.logicals.is_empty() && summary.effect.detectors.len() == 2 { + if summary.effect.dem_outputs.is_empty() && summary.effect.detectors.len() == 2 { summary.graphlike_decomposable_count = self.graphlike_decomposable_count( summary.effect.detectors[0], summary.effect.detectors[1], @@ -1746,8 +3310,8 @@ impl DetectorErrorModel { } // Y-containing channels are always classified as YDecomposed for source - // tracking purposes. This matches Stim's behavior where Y channels - // contribute to decomposed output forms regardless of component structure. + // tracking purposes so they can contribute to decomposed output forms + // regardless of component structure. // // The combined effect is X XOR Z. When one is empty, the combined effect // is just the non-empty one. @@ -1823,11 +3387,51 @@ impl DetectorErrorModel { /// /// This should be called from the builder when processing 2-qubit gate channels /// where both component effects are graphlike. + /// Returns grouped mechanisms as (probability, `detector_ids`, `observable_ids`) tuples. + /// + /// Combines contributions with the same effect using the XOR probability formula. + /// Also returns detector coordinate map. This is the structured equivalent of + /// `to_string()` — same data, no string intermediary. + #[must_use] + pub fn to_mechanisms(&self) -> (Vec, Vec) { + // Group contributions by effect + 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 mechanisms: Vec<(f64, Vec, Vec)> = by_effect + .into_iter() + .filter(|(effect, prob)| !effect.is_standard_empty() && *prob > 0.0) + .map(|(effect, prob)| (prob, effect.detectors.to_vec(), effect.dem_outputs.to_vec())) + .collect(); + + let coords: Vec<(u32, Vec)> = self + .detectors + .iter() + .filter_map(|det| det.coords.map(|[x, y, z]| (det.id, vec![x, y, z]))) + .collect(); + + (mechanisms, coords) + } + pub fn mark_graphlike_decomposable(&mut self, d0: u32, d1: u32) { let key = if d0 < d1 { (d0, d1) } else { (d1, d0) }; *self.graphlike_decomposable_counts.entry(key).or_insert(0) += 1; } + /// Merge contributions and graphlike counts from another DEM. + /// Used for parallelized DEM construction. + pub fn merge_contributions_from(&mut self, other: Self) { + self.contributions.extend(other.contributions); + for (key, count) in other.graphlike_decomposable_counts { + *self.graphlike_decomposable_counts.entry(key).or_insert(0) += count; + } + } + /// Returns the number of graphlike decomposable sources for a 2-detector mechanism. #[must_use] pub fn graphlike_decomposable_count(&self, d0: u32, d1: u32) -> u32 { @@ -1843,16 +3447,71 @@ impl DetectorErrorModel { self.detectors.push(detector); } - /// Adds a logical observable definition. - pub fn add_observable(&mut self, observable: LogicalObservable) { + /// Adds a non-detector DEM output definition. + /// + /// Observables are stored in the standard `L` namespace. Tracked + /// Paulis are stored in PECOS metadata with a separate ID space. + pub fn add_dem_output(&mut self, output: DemOutput) { + if output.is_tracked_pauli() { + self.add_tracked_pauli(output); + } else { + self.add_observable(output); + } + } + + /// Adds a standard DEM observable (`L`) definition. + pub fn add_observable(&mut self, mut observable: DemOutput) { + observable.kind = Some(DemOutputKind::Observable); + if let Some(existing) = self + .observables + .iter_mut() + .find(|existing| existing.id == observable.id) + { + Self::merge_observable_definition(existing, observable); + return; + } self.observables.push(observable); } + fn merge_observable_definition(existing: &mut DemOutput, incoming: DemOutput) { + existing.kind = Some(DemOutputKind::Observable); + merge_record_parity(&mut existing.records, incoming.records); + + if let Some(incoming_pauli) = incoming.pauli { + if let Some(existing_pauli) = &existing.pauli { + debug_assert_eq!( + existing_pauli, &incoming_pauli, + "conflicting Pauli metadata for observable L{}", + existing.id + ); + } else { + existing.pauli = Some(incoming_pauli); + } + } + + if let Some(incoming_label) = incoming.label { + if let Some(existing_label) = &existing.label { + debug_assert_eq!( + existing_label, &incoming_label, + "conflicting labels for observable L{}", + existing.id + ); + } else { + existing.label = Some(incoming_label); + } + } + } + + /// Adds a PECOS tracked Pauli definition. + pub fn add_tracked_pauli(&mut self, mut tracked_pauli: DemOutput) { + tracked_pauli.kind = Some(DemOutputKind::TrackedPauli); + self.tracked_paulis.push(tracked_pauli); + } + /// Converts the DEM to a string in standard DEM format. /// /// Each fault mechanism is output with its total probability, with no - /// splitting into decomposed forms. This matches Stim's - /// `detector_error_model(decompose_errors=False)` output. + /// splitting into decomposed forms. /// /// Requires source tracking to be enabled and contributions to be populated. /// Use `build_with_source_tracking()` to create a DEM with contributions. @@ -1870,7 +3529,7 @@ impl DetectorErrorModel { } } - // Add logical observable annotations + // Add standard observable annotations for obs in &self.observables { lines.push(format!("logical_observable L{}", obs.id)); } @@ -1880,14 +3539,14 @@ impl DetectorErrorModel { let mut by_effect: BTreeMap = BTreeMap::new(); for contrib in &self.contributions { by_effect - .entry(contrib.effect.clone()) + .entry(contrib.effect.standard_effect()) .and_modify(|p| *p = combine_independent_probs(*p, contrib.probability)) .or_insert(contrib.probability); } // Output each mechanism with its total probability for (effect, total_prob) in by_effect { - if effect.is_empty() || total_prob <= 0.0 { + if effect.is_standard_empty() || total_prob <= 0.0 { continue; } @@ -1986,7 +3645,7 @@ impl DetectorErrorModel { 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.logicals.is_empty() { + } 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!( @@ -2007,7 +3666,7 @@ impl DetectorErrorModel { return (cached.clone(), strategy, recorded_component_targets); } - let effect = &contrib.effect; + let effect = contrib.effect.standard_effect(); let (targets, strategy) = if let Some((x_effect, z_effect)) = contrib.decomposition_components() { @@ -2032,8 +3691,8 @@ impl DetectorErrorModel { targets }; (targets, ContributionRenderStrategy::SourceComponents) - } else if effect.num_detectors() == 2 && effect.logicals.is_empty() { - let direct_targets = Self::two_detector_direct_targets(effect, singleton_set); + } else if effect.num_detectors() == 2 && effect.dem_outputs.is_empty() { + let direct_targets = Self::two_detector_direct_targets(&effect, singleton_set); if matches!( two_detector_direct_policy, TwoDetectorDirectRenderPolicy::PreferRecordedComponents @@ -2063,7 +3722,7 @@ impl DetectorErrorModel { ) } } else if effect.is_hyperedge() { - if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(effect) { + if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { ( Self::maybe_maximally_decompose_parts(decomp, singleton_set) .iter() @@ -2074,7 +3733,7 @@ impl DetectorErrorModel { ) } else { ( - format_mechanism_targets(effect), + format_mechanism_targets(&effect), ContributionRenderStrategy::EffectDirect, ) } @@ -2088,8 +3747,8 @@ impl DetectorErrorModel { ContributionRenderStrategy::EffectDirect, ) } - } else if effect.num_detectors() == 2 && effect.logicals.is_empty() { - let direct_targets = Self::two_detector_direct_targets(effect, singleton_set); + } else if effect.num_detectors() == 2 && effect.dem_outputs.is_empty() { + let direct_targets = Self::two_detector_direct_targets(&effect, singleton_set); if matches!( two_detector_direct_policy, TwoDetectorDirectRenderPolicy::PreferRecordedComponents @@ -2119,7 +3778,7 @@ impl DetectorErrorModel { ) } } else if effect.is_hyperedge() { - if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(effect) { + if let Some(decomp) = graphlike_index.find_hyperedge_decomposition(&effect) { ( Self::maybe_maximally_decompose_parts(decomp, singleton_set) .iter() @@ -2130,7 +3789,7 @@ impl DetectorErrorModel { ) } else { ( - format_mechanism_targets(effect), + format_mechanism_targets(&effect), ContributionRenderStrategy::EffectDirect, ) } @@ -2166,9 +3825,8 @@ impl DetectorErrorModel { .0 } - /// Converts the DEM to Stim format using source tracking (decomposed format). + /// Converts the DEM to decomposed text using source tracking. /// - /// This matches Stim's `detector_error_model(decompose_errors=True)` output. /// Fault mechanisms are split into direct and decomposed forms based on /// their source types (X/Z vs Y errors). /// @@ -2188,7 +3846,7 @@ impl DetectorErrorModel { /// /// Hyperedges (3+ detectors) are decomposed into graphlike forms when /// possible. Mechanisms with up to 2 detectors are already graphlike even - /// when they carry multiple logical observables. + /// when they carry multiple DEM outputs. #[must_use] fn to_string_decomposed_inner( &self, @@ -2206,7 +3864,7 @@ impl DetectorErrorModel { } } - // Add logical observable annotations + // Add standard observable annotations for obs in &self.observables { lines.push(format!("logical_observable L{}", obs.id)); } @@ -2229,8 +3887,8 @@ impl DetectorErrorModel { }; // Process each tracked contribution individually, then regroup identical - // decomposed outputs. This is closer to Stim's decomposition pass, which - // rewrites each error class before merging identical rewritten targets. + // decomposed outputs. Rewriting each error class before merging keeps + // source-aware decompositions stable. for contrib in &self.contributions { if contrib.effect.is_empty() || contrib.probability <= 0.0 { continue; @@ -2305,8 +3963,9 @@ impl DetectorErrorModel { fn collect_graphlike_mechanisms(&self) -> BTreeSet { let mut graphlike = BTreeSet::new(); for contrib in &self.contributions { - if contrib.effect.is_graphlike() { - graphlike.insert(contrib.effect.clone()); + let standard = contrib.effect.standard_effect(); + if !standard.is_standard_empty() && standard.is_graphlike() { + graphlike.insert(standard); } } graphlike @@ -2317,541 +3976,1014 @@ impl Default for DetectorErrorModel { fn default() -> Self { Self::new() } -} +} + +// ============================================================================ +// Probability Combination +// ============================================================================ + +/// Combines two independent error probabilities. +/// +/// For independent errors with probabilities p1 and p2, the probability +/// that exactly one error occurs is: p1*(1-p2) + p2*(1-p1). +/// +/// This is the correct formula for combining probabilities when the same +/// fault mechanism can be triggered by multiple independent error sources. +#[inline] +#[must_use] +pub fn combine_probabilities(p1: f64, p2: f64) -> f64 { + p1 * (1.0 - p2) + p2 * (1.0 - p1) +} + +/// Formats an fault mechanism's targets as a string (e.g., "D0 D1 L0"). +fn format_mechanism_targets(mechanism: &FaultMechanism) -> String { + let mut targets = Vec::new(); + for &det in &mechanism.detectors { + targets.push(format!("D{det}")); + } + for &dem_output in &mechanism.dem_outputs { + targets.push(format!("L{dem_output}")); + } + targets.join(" ") +} + +/// Formats a PECOS DEM mechanism's targets, including tracked Pauli `TP` outputs. +fn format_pecos_mechanism_targets(mechanism: &FaultMechanism) -> String { + let mut targets = Vec::new(); + for &det in &mechanism.detectors { + targets.push(format!("D{det}")); + } + for &dem_output in &mechanism.dem_outputs { + targets.push(format!("L{dem_output}")); + } + for &tracked_pauli in &mechanism.tracked_paulis { + targets.push(format!("TP{tracked_pauli}")); + } + targets.join(" ") +} + +/// Combines two independent error probabilities. +/// +/// For two independent errors with probabilities p1 and p2, the combined +/// probability of having an odd number of errors (i.e., the XOR of the effects) is: +/// `p_combined` = p1*(1-p2) + p2*(1-p1) +fn combine_independent_probs(p1: f64, p2: f64) -> f64 { + // For DEM probability aggregation, we use XOR combination because + // errors toggle detector bits - if two errors both flip the same detector, + // they cancel out (XOR behavior). We want P(odd number of errors). + // XOR formula: P(A XOR B) = P(A)*(1-P(B)) + P(B)*(1-P(A)) = p1 + p2 - 2*p1*p2 + p1 * (1.0 - p2) + p2 * (1.0 - p1) +} + +/// Formats a probability value similar to Python's %g format. +/// Uses scientific notation for very small/large values, otherwise decimal. +fn format_probability(p: f64) -> String { + if p == 0.0 { + return "0".to_string(); + } + + let abs_p = p.abs(); + + // Use scientific notation for very small or very large values + if (1e-4..1e6).contains(&abs_p) { + // Regular decimal notation + let formatted = format!("{p:.6}"); + trim_trailing_zeros(&formatted) + } else { + // Format with up to 6 significant figures in scientific notation + let formatted = format!("{p:.6e}"); + // Trim trailing zeros after decimal point + trim_trailing_zeros(&formatted) + } +} + +/// Trims trailing zeros from a number string. +fn trim_trailing_zeros(s: &str) -> String { + if let Some(e_pos) = s.find('e') { + // Scientific notation: trim zeros before 'e' + let (mantissa, exponent) = s.split_at(e_pos); + let trimmed = mantissa.trim_end_matches('0').trim_end_matches('.'); + format!("{trimmed}{exponent}") + } else if s.contains('.') { + // Decimal notation: trim trailing zeros + s.trim_end_matches('0').trim_end_matches('.').to_string() + } else { + s.to_string() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_error_mechanism_xor() { + let m1 = FaultMechanism::from_unsorted([0, 1, 2], [0]); + let m2 = FaultMechanism::from_unsorted([1, 2, 3], [0, 1]); + + let result = m1.xor(&m2); + + // Detectors: {0, 1, 2} XOR {1, 2, 3} = {0, 3} + assert_eq!(result.detectors.as_slice(), &[0, 3]); + // DEM outputs: {0} XOR {0, 1} = {1} + assert_eq!(result.dem_outputs.as_slice(), &[1]); + } + + #[test] + fn test_error_mechanism_equality() { + let m1 = FaultMechanism::from_unsorted([2, 0, 1], [1, 0]); + let m2 = FaultMechanism::from_unsorted([0, 1, 2], [0, 1]); + + assert_eq!(m1, m2); + assert_eq!(m1.detectors.as_slice(), &[0, 1, 2]); + assert_eq!(m1.dem_outputs.as_slice(), &[0, 1]); + } + + #[test] + fn test_error_mechanism_equality_and_hash_include_tracked_paulis() { + let standard = FaultMechanism::from_unsorted([0], []); + let with_tracked = FaultMechanism::from_unsorted_with_tracked_paulis([0], [], [0]); + + assert_ne!(standard, with_tracked); + assert_eq!(standard.standard_effect(), with_tracked.standard_effect()); + + let mut set = std::collections::HashSet::new(); + set.insert(standard); + set.insert(with_tracked); + assert_eq!( + set.len(), + 2, + "internal mechanism identity must keep tracked Paulis distinct" + ); + } + + #[test] + fn test_pecos_target_format_canonicalizes_tracked_paulis() { + let mechanism = FaultMechanism::from_unsorted_with_tracked_paulis([], [], [2, 0]); + assert_eq!( + DecomposedFault::single(mechanism).to_pecos_targets(), + "TP0 TP2" + ); + } + + #[test] + fn test_combine_probabilities() { + // Same probability twice + let p = combine_probabilities(0.01, 0.01); + // Expected: 0.01 * 0.99 + 0.01 * 0.99 = 0.0198 + assert!((p - 0.0198).abs() < 1e-10); + + // One zero probability + assert!((combine_probabilities(0.0, 0.5) - 0.5).abs() < 1e-10); + assert!((combine_probabilities(0.5, 0.0) - 0.5).abs() < 1e-10); + + // Both zero + assert!((combine_probabilities(0.0, 0.0)).abs() < 1e-10); + } -// ============================================================================ -// Measurement Noise Model (MNM) -// ============================================================================ + #[test] + fn test_pecos_metadata_json_preserves_tracked_paulis() { + use pecos_core::pauli::{X, Z}; -/// A measurement fault mechanism: a set of measurements that flip together. -/// -/// Unlike [`FaultMechanism`] which operates on detectors, this operates directly -/// on raw measurement indices. This is useful for sampling measurement outcomes -/// without needing detector definitions. -/// -/// Measurements are stored in sorted order for canonical representation. -#[derive(Clone, Default)] -pub struct MeasurementMechanism { - /// Measurement indices that flip together (sorted). - pub measurements: SmallVec<[u32; 4]>, -} + let mut dem = DetectorErrorModel::new(); + dem.add_dem_output( + DemOutput::new(0) + .with_kind(DemOutputKind::TrackedPauli) + .with_pauli(X(0) & Z(2)) + .with_label("track_check"), + ); + dem.add_dem_output(DemOutput::new(1).with_records([-1, -3])); -impl MeasurementMechanism { - /// Creates a new empty measurement mechanism. - #[must_use] - pub fn new() -> Self { - Self::default() - } + let metadata: serde_json::Value = + serde_json::from_str(&dem.to_pecos_metadata_json()).unwrap(); + let observables = metadata["observables"].as_array().unwrap(); + let tracked_paulis = metadata["tracked_paulis"].as_array().unwrap(); - /// Creates a mechanism from unsorted measurement indices. - #[must_use] - pub fn from_unsorted(measurements: impl IntoIterator) -> Self { - let mut meas: SmallVec<[u32; 4]> = measurements.into_iter().collect(); - meas.sort_unstable(); - Self { measurements: meas } + assert_eq!(metadata["format"], "pecos.dem.metadata"); + assert_eq!(metadata["version"], 1); + assert_eq!(tracked_paulis[0]["id"], 0); + assert_eq!(tracked_paulis[0]["kind"], "tracked_pauli"); + assert_eq!(tracked_paulis[0]["label"], "track_check"); + assert_eq!(tracked_paulis[0]["pauli"], "+X0 Z2"); + assert_eq!(observables[0]["id"], 1); + assert_eq!(observables[0]["kind"], "observable"); + assert_eq!(observables[0]["records"], serde_json::json!([-1, -3])); } - /// Creates a mechanism from pre-sorted measurement indices. - #[must_use] - pub fn from_sorted(measurements: SmallVec<[u32; 4]>) -> Self { - debug_assert!( - measurements.windows(2).all(|w| w[0] <= w[1]), - "measurements must be sorted" + #[test] + fn test_dem_counts_keep_detectors_observables_and_tracked_paulis_distinct() { + use pecos_core::pauli::X; + + let mut dem = DetectorErrorModel::new(); + dem.add_detector(DetectorDef::new(0).with_records([-1])); + dem.add_dem_output(DemOutput::new(0).with_records([-1, -3])); + dem.add_dem_output( + DemOutput::new(0) + .with_kind(DemOutputKind::TrackedPauli) + .with_pauli(X(0)), ); - Self { measurements } - } - /// Returns true if this mechanism has no effect (empty). - #[inline] - #[must_use] - pub fn is_empty(&self) -> bool { - self.measurements.is_empty() + assert_eq!(dem.num_detectors(), 1); + assert_eq!(dem.num_dem_outputs(), 1); + assert_eq!(dem.num_observables(), 1); + assert_eq!(dem.num_tracked_paulis(), 1); + assert_eq!(dem.observables().map(|op| op.id).collect::>(), [0]); + assert_eq!( + dem.tracked_paulis() + .iter() + .map(|op| op.id) + .collect::>(), + [0] + ); } - /// Returns the number of measurements in this mechanism. - #[inline] - #[must_use] - pub fn len(&self) -> usize { - self.measurements.len() - } -} + #[test] + fn test_duplicate_observable_definitions_merge_records_by_parity() { + use pecos_core::pauli::X; -impl PartialEq for MeasurementMechanism { - fn eq(&self, other: &Self) -> bool { - self.measurements == other.measurements + let mut dem = DetectorErrorModel::new(); + dem.add_observable( + DemOutput::new(0) + .with_records([-1, -2]) + .with_pauli(X(0)) + .with_label("logical_z"), + ); + dem.add_observable( + DemOutput::new(0) + .with_records([-2, -3]) + .with_pauli(X(0)) + .with_label("logical_z"), + ); + + assert_eq!(dem.num_observables(), 1); + let observable = &dem.dem_outputs()[0]; + assert_eq!(observable.records.as_slice(), &[-1, -3]); + assert_eq!(observable.pauli.as_ref().unwrap().to_sparse_str(), "+X0"); + assert_eq!(observable.label.as_deref(), Some("logical_z")); } -} -impl Eq for MeasurementMechanism {} + #[test] + fn test_observable_records_are_stored_by_xor_parity() { + let mut dem = DetectorErrorModel::new(); + dem.add_observable(DemOutput::new(0).with_records([-1, -2, -1, -3])); -impl Hash for MeasurementMechanism { - fn hash(&self, state: &mut H) { - self.measurements.hash(state); + assert_eq!(dem.dem_outputs()[0].records.as_slice(), &[-2, -3]); } -} -impl PartialOrd for MeasurementMechanism { - fn partial_cmp(&self, other: &Self) -> Option { - Some(self.cmp(other)) + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "conflicting labels for observable L0")] + fn test_duplicate_observable_definitions_reject_conflicting_labels() { + let mut dem = DetectorErrorModel::new(); + dem.add_observable(DemOutput::new(0).with_label("first")); + dem.add_observable(DemOutput::new(0).with_label("second")); } -} -impl Ord for MeasurementMechanism { - fn cmp(&self, other: &Self) -> Ordering { - self.measurements.cmp(&other.measurements) - } -} + #[test] + #[cfg(debug_assertions)] + #[should_panic(expected = "conflicting Pauli metadata for observable L0")] + fn test_duplicate_observable_definitions_reject_conflicting_paulis() { + use pecos_core::pauli::{X, Z}; -impl fmt::Debug for MeasurementMechanism { - fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - write!( - f, - "MeasurementMechanism({:?})", - self.measurements.as_slice() - ) + let mut dem = DetectorErrorModel::new(); + dem.add_observable(DemOutput::new(0).with_pauli(X(0))); + dem.add_observable(DemOutput::new(0).with_pauli(Z(0))); } -} -/// A Measurement Noise Model (MNM) for fast approximate sampling. -/// -/// Unlike a DEM which maps fault mechanisms to detector effects, the MNM maps -/// directly to measurement effects. This allows sampling raw measurement outcomes -/// without needing detector definitions. -/// -/// # Sampling Modes -/// -/// - **Per-fault-location** (accurate): Sample each (location, Pauli) independently -/// - **Per-mechanism** (fast, approximate): Sample each unique measurement effect once -/// -/// The MNM enables the fast per-mechanism mode while still producing raw measurement -/// outcomes that can be converted to detection events using any detector definition. -/// -/// # Example -/// -/// Build an MNM from a fault influence map and sample measurement outcomes. -/// In practice you will use [`MemBuilder`] to populate mechanisms; here we -/// use an empty MNM to keep the doctest self-contained. -/// -/// ``` -/// use pecos_qec::fault_tolerance::dem_builder::MeasurementNoiseModel; -/// use rand::SeedableRng; -/// use rand::rngs::StdRng; -/// -/// let num_measurements = 4; -/// let mnm = MeasurementNoiseModel::new(num_measurements); -/// -/// let mut outcomes = vec![false; num_measurements]; -/// let mut rng = StdRng::seed_from_u64(0); -/// mnm.sample_into(&mut outcomes, &mut rng); -/// ``` -#[derive(Debug, Clone, Default)] -pub struct MeasurementNoiseModel { - /// Fault mechanisms mapped to their probabilities. - /// Uses `BTreeMap` for deterministic iteration order. - pub mechanisms: BTreeMap, - /// Total number of measurements in the circuit. - pub num_measurements: usize, - /// Optional mapping from influence map index to `TickCircuit` index. - /// If set, outcomes are reordered before detection event conversion. - /// `im_to_tc`[`im_idx`] = `tc_idx` - pub im_to_tc_order: Option>, -} + #[test] + fn test_dem_output_kind_predicates_are_mutually_exclusive() { + use pecos_core::pauli::X; -impl MeasurementNoiseModel { - /// Creates a new empty MNM. - #[must_use] - pub fn new(num_measurements: usize) -> Self { - Self { - mechanisms: BTreeMap::new(), - num_measurements, - im_to_tc_order: None, - } + let observable = DemOutput::new(0) + .with_kind(DemOutputKind::Observable) + .with_pauli(X(0)); + assert!(observable.is_observable()); + assert!(!observable.is_tracked_pauli()); + + let tracked = DemOutput::new(0) + .with_kind(DemOutputKind::TrackedPauli) + .with_records([-1]); + assert!(!tracked.is_observable()); + assert!(tracked.is_tracked_pauli()); + + let inferred_observable = DemOutput::new(1).with_records([-1]); + assert!(inferred_observable.is_observable()); + assert!(!inferred_observable.is_tracked_pauli()); + + let inferred_tracked = DemOutput::new(1).with_pauli(X(1)); + assert!(!inferred_tracked.is_observable()); + assert!(inferred_tracked.is_tracked_pauli()); } - /// Sets the measurement order mapping from influence map to `TickCircuit` order. - /// - /// This is needed when detector definitions use `TickCircuit` measurement indices - /// but the influence map uses a different ordering. - /// - /// # Arguments - /// - /// * `im_to_tc` - Mapping where `im_to_tc[im_idx] = tc_idx` - #[must_use] - pub fn with_measurement_order(mut self, im_to_tc: Vec) -> Self { - self.im_to_tc_order = Some(im_to_tc); - self + #[test] + fn test_generic_dem_output_metadata_uses_consistent_kind_name() { + let mut dem = DetectorErrorModel::new(); + dem.add_dem_output(DemOutput::new(0)); + + let metadata: serde_json::Value = + serde_json::from_str(&dem.to_pecos_metadata_json()).unwrap(); + let ops = metadata["observables"].as_array().unwrap(); + + assert_eq!(ops[0]["kind"], "observable"); + + let recovered = DetectorErrorModel::new() + .with_pecos_metadata_json(&dem.to_pecos_metadata_json()) + .unwrap(); + assert_eq!(recovered.num_dem_outputs(), 1); + assert_eq!(recovered.num_tracked_paulis(), 0); + assert_eq!(recovered.dem_outputs()[0].id, 0); + assert_eq!( + recovered.dem_outputs()[0].kind, + Some(DemOutputKind::Observable) + ); } - /// Sets the measurement order mapping (mutable version). - pub fn set_measurement_order(&mut self, im_to_tc: Vec) { - self.im_to_tc_order = Some(im_to_tc); + #[test] + fn test_pecos_metadata_json_round_trips_tracked_pauli_metadata() { + use pecos_core::pauli::{X, Z}; + + let mut dem = DetectorErrorModel::new(); + dem.add_dem_output(DemOutput::new(0)); + dem.add_dem_output(DemOutput::new(1)); + + let mut source = DetectorErrorModel::new(); + source.add_dem_output( + DemOutput::new(0) + .with_kind(DemOutputKind::TrackedPauli) + .with_pauli(X(0) & Z(2)) + .with_label("track_check"), + ); + source.add_dem_output(DemOutput::new(1).with_records([-1, -3])); + + dem.apply_pecos_metadata_json(&source.to_pecos_metadata_json()) + .unwrap(); + + assert_eq!( + dem.tracked_paulis()[0].kind, + Some(DemOutputKind::TrackedPauli) + ); + assert_eq!( + dem.tracked_paulis()[0].label.as_deref(), + Some("track_check") + ); + assert_eq!( + dem.tracked_paulis()[0] + .pauli + .as_ref() + .unwrap() + .to_sparse_str(), + "+X0 Z2" + ); + assert_eq!(dem.dem_outputs()[1].kind, Some(DemOutputKind::Observable)); + assert_eq!(dem.dem_outputs()[1].records.as_slice(), &[-1, -3]); } - /// Returns the number of distinct mechanisms. - #[inline] - #[must_use] - pub fn num_mechanisms(&self) -> usize { - self.mechanisms.len() + #[test] + fn test_pecos_metadata_json_parser_requires_output_arrays() { + let old_metadata_json = r#"{ + "format": "pecos.dem.metadata", + "version": 1, + "old_outputs": [ + { + "id": 4, + "kind": "old_kind", + "label": "old_name", + "pauli": null, + "records": [] + } + ] + }"#; + + let err = DetectorErrorModel::new() + .with_pecos_metadata_json(old_metadata_json) + .unwrap_err(); + assert!( + err.message() + .contains("missing observables or tracked_paulis metadata arrays") + ); } - /// Adds an fault mechanism with the given probability. - /// - /// If the mechanism already exists, probabilities are combined - /// using the independent error formula: p1*(1-p2) + p2*(1-p1). - pub fn add_mechanism(&mut self, mechanism: MeasurementMechanism, probability: f64) { - if mechanism.is_empty() || probability <= 0.0 { - return; - } + #[test] + fn test_pecos_metadata_json_parser_rejects_legacy_tracked_fields() { + let json = r#"{ + "format": "pecos.dem.metadata", + "version": 1, + "observables": [], + "tracked_paulis": [], + "tracked_ops": [ + { + "id": 0, + "kind": "tracked_op", + "label": "old_name", + "pauli": "+X0", + "records": [] + } + ] + }"#; - self.mechanisms - .entry(mechanism) - .and_modify(|p| *p = combine_probabilities(*p, probability)) - .or_insert(probability); + let err = DetectorErrorModel::new() + .with_pecos_metadata_json(json) + .unwrap_err(); + assert!( + err.message() + .contains("unsupported legacy metadata field: tracked_ops; use tracked_paulis") + ); } - /// Samples measurement outcomes into the provided buffer. - /// - /// Each mechanism is sampled once according to its probability. - /// When a mechanism fires, its measurements are XOR'd into the outcomes. - /// - /// # Arguments - /// - /// * `outcomes` - Buffer to store measurement outcomes (must be pre-sized) - /// * `rng` - Random number generator - pub fn sample_into(&self, outcomes: &mut [bool], rng: &mut R) { - // Clear outcomes - outcomes.fill(false); + #[test] + fn test_pecos_metadata_json_parser_rejects_old_generic_kind_names() { + let json = r#"{ + "format": "pecos.dem.metadata", + "version": 1, + "tracked_paulis": [ + { + "id": 4, + "kind": "old_kind", + "label": "old_name", + "pauli": null, + "records": [] + } + ] + }"#; - for (mechanism, &prob) in &self.mechanisms { - if rng.random::() < prob { - for &meas_idx in &mechanism.measurements { - if (meas_idx as usize) < outcomes.len() { - outcomes[meas_idx as usize] ^= true; - } + let err = DetectorErrorModel::new() + .with_pecos_metadata_json(json) + .unwrap_err(); + assert!( + err.message() + .contains("DEM output 0 has unknown kind: old_kind") + ); + + let alias_json = r#"{ + "format": "pecos.dem.metadata", + "version": 1, + "tracked_paulis": [ + { + "id": 4, + "kind": "pauli_operator", + "label": "old_alias", + "pauli": "+X0", + "records": [] } - } - } + ] + }"#; + let err = DetectorErrorModel::new() + .with_pecos_metadata_json(alias_json) + .unwrap_err(); + assert!( + err.message() + .contains("DEM output 0 has unknown kind: pauli_operator") + ); } - /// Samples and returns measurement outcomes as a vector. - #[must_use] - pub fn sample(&self, rng: &mut R) -> Vec { - let mut outcomes = vec![false; self.num_measurements]; - self.sample_into(&mut outcomes, rng); - outcomes + #[test] + fn test_pecos_metadata_json_rejects_records_on_tracked_pauli() { + let json = r#"{ + "format": "pecos.dem.metadata", + "version": 1, + "tracked_paulis": [ + { + "id": 0, + "kind": "tracked_pauli", + "pauli": "X0", + "records": [-1] + } + ] + }"#; + + let err = DetectorErrorModel::new() + .with_pecos_metadata_json(json) + .unwrap_err(); + assert!( + err.message() + .contains("tracked Pauli DEM output 0 cannot have measurement records") + ); } - /// Iterates over all mechanisms and their probabilities. - pub fn iter(&self) -> impl Iterator { - self.mechanisms.iter() + #[test] + fn test_pecos_dem_text_is_stim_superset_with_dem_output_metadata() { + use pecos_core::pauli::{X, Z}; + + let mut dem = DetectorErrorModel::new(); + dem.add_detector(DetectorDef::new(0)); + dem.add_dem_output( + DemOutput::new(0) + .with_kind(DemOutputKind::TrackedPauli) + .with_pauli(X(0) & Z(2)) + .with_label("track_check"), + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([0], [], [0]), + 0.01, + ); + + let stim_text = dem.to_string(); + assert!(!stim_text.contains("logical_observable L0")); + assert!(stim_text.contains("error(0.01) D0")); + assert!(!stim_text.contains("TP0")); + assert!(!stim_text.contains("pecos_")); + + let pecos_text = dem.to_pecos_string(); + assert!(pecos_text.contains("error(0.01) D0 TP0")); + assert!(pecos_text.contains("pecos_tracked_pauli")); + assert!(pecos_text.contains(r#""kind":"tracked_pauli""#)); + assert!(pecos_text.contains(r#""pauli":"+X0 Z2""#)); + + let recovered = DetectorErrorModel::new() + .with_pecos_dem_metadata(&pecos_text) + .unwrap(); + assert_eq!(recovered.num_dem_outputs(), 0); + assert_eq!(recovered.num_tracked_paulis(), 1); + assert_eq!( + recovered.tracked_paulis()[0].kind, + Some(DemOutputKind::TrackedPauli) + ); + assert_eq!( + recovered.tracked_paulis()[0] + .pauli + .as_ref() + .unwrap() + .to_sparse_str(), + "+X0 Z2" + ); + assert_eq!( + recovered.tracked_paulis()[0].label.as_deref(), + Some("track_check") + ); } - /// Converts measurement outcomes to detection events. - /// - /// Given raw measurement outcomes and detector definitions (as measurement indices), - /// computes which detectors fire by XOR'ing the specified measurements for each detector. - /// - /// If `im_to_tc_order` is set, outcomes are first reordered from influence map - /// order to `TickCircuit` order before applying detector records. - /// - /// # Arguments - /// - /// * `outcomes` - Raw measurement outcomes in influence map order (from `sample()`) - /// * `detector_records` - For each detector, the list of measurement indices to XOR. - /// Indices can be negative (offset from end) or positive (absolute). - /// These indices refer to `TickCircuit` measurement order. - /// - /// # Returns - /// - /// Vector of detection events (true = detector fired) - #[must_use] - pub fn compute_detection_events( - &self, - outcomes: &[bool], - detector_records: &[Vec], - ) -> Vec { - // Reorder outcomes from IM order to TC order if mapping is set - let tc_outcomes: Vec = if let Some(ref im_to_tc) = self.im_to_tc_order { - let mut reordered = vec![false; outcomes.len()]; - for (im_idx, &tc_idx) in im_to_tc.iter().enumerate() { - if im_idx < outcomes.len() && tc_idx < reordered.len() { - reordered[tc_idx] = outcomes[im_idx]; - } - } - reordered - } else { - outcomes.to_vec() - }; + #[test] + fn test_pecos_dem_text_round_trips_observables_and_tracked_paulis() { + use pecos_core::pauli::Z; - Self::to_detection_events_internal(&tc_outcomes, detector_records) + let mut dem = DetectorErrorModel::new(); + dem.add_detector(DetectorDef::new(0)); + dem.add_dem_output(DemOutput::new(0).with_records([-1])); + dem.add_dem_output(DemOutput::new(1).with_records([-2])); + dem.add_dem_output( + DemOutput::new(0) + .with_kind(DemOutputKind::TrackedPauli) + .with_pauli(Z(3)) + .with_label("tracked_z3"), + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([0], [0], [0]), + 0.01, + ); + dem.add_direct_contribution(FaultMechanism::from_unsorted([], [1]), 0.02); + + let stim_text = dem.to_string(); + assert!(stim_text.contains("logical_observable L0")); + assert!(stim_text.contains("logical_observable L1")); + assert!(!stim_text.contains("logical_observable L2")); + assert!(!stim_text.contains("TP0")); + assert!(!stim_text.contains("pecos_tracked_pauli")); + + let pecos_text = dem.to_pecos_string(); + assert!(pecos_text.contains("error(0.01) D0 L0 TP0")); + assert!(pecos_text.contains("pecos_observable")); + assert!(pecos_text.contains("pecos_tracked_pauli")); + + let recovered = DetectorErrorModel::new() + .with_pecos_dem_metadata(&pecos_text) + .unwrap(); + assert_eq!(recovered.num_observables(), 2); + assert_eq!(recovered.num_dem_outputs(), 2); + assert_eq!(recovered.num_tracked_paulis(), 1); + assert_eq!( + recovered + .dem_outputs() + .iter() + .map(|op| op.id) + .collect::>(), + [0, 1] + ); + assert_eq!( + recovered + .tracked_paulis() + .iter() + .map(|op| op.id) + .collect::>(), + [0] + ); + assert_eq!( + recovered.tracked_paulis()[0] + .pauli + .as_ref() + .unwrap() + .to_sparse_str(), + "+Z3" + ); + assert_eq!( + recovered.tracked_paulis()[0].label.as_deref(), + Some("tracked_z3") + ); } - /// Internal static helper for detection event conversion. - fn to_detection_events_internal(outcomes: &[bool], detector_records: &[Vec]) -> Vec { - let num_measurements = outcomes.len(); - let mut detection_events = Vec::with_capacity(detector_records.len()); - - for records in detector_records { - let mut fired = false; - for &offset in records { - if let Some(abs_idx) = record_offset_to_absolute_index(num_measurements, offset) - && abs_idx < num_measurements - && outcomes[abs_idx] - { - fired = !fired; // XOR - } - } - detection_events.push(fired); - } + #[test] + fn test_pecos_dem_text_parses_error_targets_and_metadata() { + use crate::fault_tolerance::dem_builder::ParsedDem; + use pecos_core::pauli::{X, Z}; - detection_events - } + let mut dem = DetectorErrorModel::new(); + dem.add_detector(DetectorDef::new(0).with_coords([1.0, 2.0, 3.0])); + dem.add_observable(DemOutput::new(0).with_records([-1]).with_label("L0")); + dem.add_tracked_pauli( + DemOutput::new(0) + .with_pauli(X(0) & Z(2)) + .with_label("tracked_x0_z2"), + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([0], [0], [0]), + 0.25, + ); - /// Static version without reordering (for backwards compatibility). - #[must_use] - pub fn to_detection_events(outcomes: &[bool], detector_records: &[Vec]) -> Vec { - Self::to_detection_events_internal(outcomes, detector_records) + let pecos_text = dem.to_pecos_string(); + let parsed: ParsedDem = pecos_text.parse().unwrap(); + + assert_eq!(parsed.num_detectors, 1); + assert_eq!(parsed.num_dem_outputs(), 1); + assert_eq!(parsed.num_tracked_paulis(), 1); + assert_eq!(parsed.mechanisms.len(), 1); + assert_eq!(parsed.mechanisms[0].format_targets(), "D0 L0 TP0"); + assert_eq!(parsed.mechanisms[0].components[0].detectors, vec![0]); + assert_eq!(parsed.mechanisms[0].components[0].observables, vec![0]); + assert_eq!(parsed.mechanisms[0].components[0].tracked_paulis, vec![0]); + assert_eq!( + parsed.dem_outputs[0].as_ref().unwrap().label.as_deref(), + Some("L0") + ); + assert_eq!( + parsed.tracked_paulis[0] + .as_ref() + .unwrap() + .pauli + .as_ref() + .unwrap() + .to_sparse_str(), + "+X0 Z2" + ); } - /// Samples and converts to detection events in one step. - /// - /// # Arguments - /// - /// * `detector_records` - For each detector, the measurement indices to XOR - /// * `rng` - Random number generator - /// - /// # Returns - /// - /// Tuple of (`measurement_outcomes_in_im_order`, `detection_events`) - pub fn sample_with_detectors( - &self, - detector_records: &[Vec], - rng: &mut R, - ) -> (Vec, Vec) { - let outcomes = self.sample(rng); - let detection_events = self.compute_detection_events(&outcomes, detector_records); - (outcomes, detection_events) - } + #[test] + fn test_tracked_only_contribution_is_pecos_only_and_decoder_invisible() { + use pecos_core::pauli::X; - /// Computes observable flips from measurement outcomes. - /// - /// This works identically to `compute_detection_events` - `XORing` measurement - /// outcomes at the specified record positions. The difference is semantic: - /// - Detection events indicate which detectors fired (syndrome) - /// - Observable flips indicate which logical observables were flipped - /// - /// # Arguments - /// - /// * `outcomes` - Raw measurement outcomes in influence map order (from `sample()`) - /// * `observable_records` - For each observable, the list of measurement indices to XOR. - /// Indices can be negative (offset from end) or positive (absolute). - /// These indices refer to `TickCircuit` measurement order. - /// - /// # Returns - /// - /// Vector of observable flips (true = observable was flipped by errors) - #[must_use] - pub fn compute_observable_flips( - &self, - outcomes: &[bool], - observable_records: &[Vec], - ) -> Vec { - // Same logic as detection events - just different semantic meaning - self.compute_detection_events(outcomes, observable_records) - } + let mut dem = DetectorErrorModel::new(); + dem.add_tracked_pauli(DemOutput::new(0).with_pauli(X(0)).with_label("tracked_x0")); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([], [], [0]), + 0.25, + ); - /// Samples with full threshold estimation output. - /// - /// Returns detection events AND observable flips in one step, matching - /// Stim's DEM sampler output format. - /// - /// # Arguments - /// - /// * `detector_records` - For each detector, the measurement indices to XOR - /// * `observable_records` - For each observable, the measurement indices to XOR - /// * `rng` - Random number generator - /// - /// # Returns - /// - /// Tuple of (`detection_events`, `observable_flips`) - pub fn sample_for_decoding( - &self, - detector_records: &[Vec], - observable_records: &[Vec], - rng: &mut R, - ) -> (Vec, Vec) { - let outcomes = self.sample(rng); - let detection_events = self.compute_detection_events(&outcomes, detector_records); - let observable_flips = self.compute_detection_events(&outcomes, observable_records); - (detection_events, observable_flips) + let standard_text = dem.to_string(); + assert!(!standard_text.contains("error(")); + assert!(!standard_text.contains("TP0")); + assert!(!standard_text.contains("pecos_tracked_pauli")); + + let pecos_text = dem.to_pecos_string(); + assert!(pecos_text.contains("error(0.25) TP0")); + assert!(pecos_text.contains("pecos_tracked_pauli")); + + let (mechanisms, coords) = dem.to_mechanisms(); + assert!(mechanisms.is_empty()); + assert!(coords.is_empty()); } - /// Batch sampling for threshold estimation. - /// - /// Efficiently samples multiple shots and returns detection events and observable - /// flips for each shot. - /// - /// # Arguments - /// - /// * `num_shots` - Number of shots to sample - /// * `detector_records` - For each detector, the measurement indices to XOR - /// * `observable_records` - For each observable, the measurement indices to XOR - /// * `rng` - Random number generator - /// - /// # Returns - /// - /// Tuple of (`detection_events_per_shot`, `observable_flips_per_shot`) - pub fn sample_batch_for_decoding( - &self, - num_shots: usize, - detector_records: &[Vec], - observable_records: &[Vec], - rng: &mut R, - ) -> (Vec>, Vec>) { - let mut all_detection_events = Vec::with_capacity(num_shots); - let mut all_observable_flips = Vec::with_capacity(num_shots); + #[test] + fn test_standard_projection_merges_effects_that_differ_only_by_tracked_paulis() { + use pecos_core::pauli::{X, Z}; - for _ in 0..num_shots { - let (det_events, obs_flips) = - self.sample_for_decoding(detector_records, observable_records, rng); - all_detection_events.push(det_events); - all_observable_flips.push(obs_flips); - } + let mut dem = DetectorErrorModel::new(); + dem.add_detector(DetectorDef::new(0)); + dem.add_tracked_pauli(DemOutput::new(0).with_pauli(X(0)).with_label("tracked_x0")); + dem.add_tracked_pauli(DemOutput::new(1).with_pauli(Z(0)).with_label("tracked_z0")); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([0], [], [0]), + 0.1, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([0], [], [1]), + 0.2, + ); - (all_detection_events, all_observable_flips) + let standard_text = dem.to_string(); + let error_lines = standard_text + .lines() + .filter(|line| line.starts_with("error(")) + .collect::>(); + assert_eq!(error_lines, ["error(0.26) D0"]); + assert!(!standard_text.contains("TP0")); + assert!(!standard_text.contains("TP1")); + + let (mechanisms, _coords) = dem.to_mechanisms(); + assert_eq!(mechanisms.len(), 1); + assert!((mechanisms[0].0 - 0.26).abs() < 1e-12); + assert_eq!(mechanisms[0].1, vec![0]); + assert!(mechanisms[0].2.is_empty()); } -} -// ============================================================================ -// Probability Combination -// ============================================================================ + #[test] + fn test_pecos_dem_preserves_effects_that_differ_by_tracked_paulis() { + use pecos_core::pauli::{X, Z}; -/// Combines two independent error probabilities. -/// -/// For independent errors with probabilities p1 and p2, the probability -/// that exactly one error occurs is: p1*(1-p2) + p2*(1-p1). -/// -/// This is the correct formula for combining probabilities when the same -/// fault mechanism can be triggered by multiple independent error sources. -#[inline] -#[must_use] -pub fn combine_probabilities(p1: f64, p2: f64) -> f64 { - p1 * (1.0 - p2) + p2 * (1.0 - p1) -} + let mut dem = DetectorErrorModel::new(); + dem.add_detector(DetectorDef::new(0)); + dem.add_tracked_pauli(DemOutput::new(0).with_pauli(X(0)).with_label("tracked_x0")); + dem.add_tracked_pauli(DemOutput::new(1).with_pauli(Z(0)).with_label("tracked_z0")); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([0], [], [0]), + 0.1, + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([0], [], [1]), + 0.2, + ); -/// Formats an fault mechanism's targets as a string (e.g., "D0 D1 L0"). -fn format_mechanism_targets(mechanism: &FaultMechanism) -> String { - let mut targets = Vec::new(); - for &det in &mechanism.detectors { - targets.push(format!("D{det}")); - } - for &log in &mechanism.logicals { - targets.push(format!("L{log}")); + let pecos_text = dem.to_pecos_string(); + let error_lines = pecos_text + .lines() + .filter(|line| line.starts_with("error(")) + .collect::>(); + assert_eq!(error_lines, ["error(0.1) D0 TP0", "error(0.2) D0 TP1"]); + assert!(pecos_text.contains(r#""label":"tracked_x0""#)); + assert!(pecos_text.contains(r#""label":"tracked_z0""#)); } - targets.join(" ") -} -/// Combines two independent error probabilities. -/// -/// For two independent errors with probabilities p1 and p2, the combined -/// probability of having an odd number of errors (i.e., the XOR of the effects) is: -/// `p_combined` = p1*(1-p2) + p2*(1-p1) -fn combine_independent_probs(p1: f64, p2: f64) -> f64 { - // For DEM probability aggregation, we use XOR combination because - // errors toggle detector bits - if two errors both flip the same detector, - // they cancel out (XOR behavior). We want P(odd number of errors). - // XOR formula: P(A XOR B) = P(A)*(1-P(B)) + P(B)*(1-P(A)) = p1 + p2 - 2*p1*p2 - p1 * (1.0 - p2) + p2 * (1.0 - p1) -} + #[test] + fn test_standard_dem_serialization_never_shifts_observable_ids_for_tracked_paulis() { + use pecos_core::pauli::{X, Z}; -/// Formats a probability value similar to Python's %g format. -/// Uses scientific notation for very small/large values, otherwise decimal. -fn format_probability(p: f64) -> String { - if p == 0.0 { - return "0".to_string(); + let mut dem = DetectorErrorModel::new(); + dem.add_detector(DetectorDef::new(0)); + dem.add_observable(DemOutput::new(0).with_records([-1]).with_label("L0")); + dem.add_observable(DemOutput::new(2).with_records([-2]).with_label("L2")); + dem.add_tracked_pauli( + DemOutput::new(0) + .with_kind(DemOutputKind::TrackedPauli) + .with_pauli(X(0)) + .with_label("tracked_x0"), + ); + dem.add_tracked_pauli( + DemOutput::new(1) + .with_kind(DemOutputKind::TrackedPauli) + .with_pauli(Z(3)) + .with_label("tracked_z3"), + ); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([0], [0, 2], [1]), + 0.01, + ); + + assert_eq!(dem.num_observables(), 3); + assert_eq!(dem.num_dem_outputs(), 3); + assert_eq!(dem.num_tracked_paulis(), 2); + + let standard_text = dem.to_string(); + assert!(standard_text.contains("logical_observable L0")); + assert!(!standard_text.contains("logical_observable L1")); + assert!(standard_text.contains("logical_observable L2")); + assert!(!standard_text.contains("logical_observable L3")); + assert!(standard_text.contains("error(0.01) D0 L0 L2")); + assert!(!standard_text.contains("TP1")); + assert!(!standard_text.contains("pecos_observable")); + assert!(!standard_text.contains("pecos_tracked_pauli")); + + let pecos_text = dem.to_pecos_string(); + assert!(pecos_text.contains("error(0.01) D0 L0 L2 TP1")); + assert!(pecos_text.contains(r#""kind":"observable""#)); + assert!(pecos_text.contains(r#""kind":"tracked_pauli""#)); + assert!(pecos_text.contains(r#""id":0"#)); + assert!(pecos_text.contains(r#""id":2"#)); + assert!(pecos_text.contains(r#""pauli":"+X0""#)); + assert!(pecos_text.contains(r#""pauli":"+Z3""#)); + + let recovered = DetectorErrorModel::new() + .with_pecos_dem_metadata(&pecos_text) + .unwrap(); + assert_eq!(recovered.num_dem_outputs(), 3); + assert_eq!(recovered.num_tracked_paulis(), 2); + assert_eq!( + recovered + .dem_outputs() + .iter() + .map(|op| op.id) + .collect::>(), + [0, 2] + ); + assert_eq!( + recovered + .tracked_paulis() + .iter() + .map(|op| op.id) + .collect::>(), + [0, 1] + ); } - let abs_p = p.abs(); + #[test] + fn test_pecos_dem_text_metadata_round_trip_keeps_observable_and_tracked_id_spaces() { + use pecos_core::pauli::{X, Y, Z}; - // Use scientific notation for very small or very large values - if (1e-4..1e6).contains(&abs_p) { - // Regular decimal notation - let formatted = format!("{p:.6}"); - trim_trailing_zeros(&formatted) - } else { - // Format with up to 6 significant figures in scientific notation - let formatted = format!("{p:.6e}"); - // Trim trailing zeros after decimal point - trim_trailing_zeros(&formatted) - } -} + let mut dem = DetectorErrorModel::new(); + dem.add_detector(DetectorDef::new(0)); + dem.add_detector(DetectorDef::new(1)); + dem.add_observable(DemOutput::new(0).with_records([-1]).with_label("L0")); + dem.add_observable( + DemOutput::new(3) + .with_records([-2, -1]) + .with_label("logical_aux"), + ); + dem.add_tracked_pauli( + DemOutput::new(0) + .with_pauli(X(0) & Z(2)) + .with_label("tracked_x0_z2"), + ); + dem.add_tracked_pauli(DemOutput::new(2).with_pauli(Y(5)).with_label("tracked_y5")); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([0, 1], [3], [2]), + 0.125, + ); -/// Trims trailing zeros from a number string. -fn trim_trailing_zeros(s: &str) -> String { - if let Some(e_pos) = s.find('e') { - // Scientific notation: trim zeros before 'e' - let (mantissa, exponent) = s.split_at(e_pos); - let trimmed = mantissa.trim_end_matches('0').trim_end_matches('.'); - format!("{trimmed}{exponent}") - } else if s.contains('.') { - // Decimal notation: trim trailing zeros - s.trim_end_matches('0').trim_end_matches('.').to_string() - } else { - s.to_string() - } -} + let standard_text = dem.to_string(); + assert!(standard_text.contains("logical_observable L0")); + assert!(!standard_text.contains("logical_observable L1")); + assert!(!standard_text.contains("logical_observable L2")); + assert!(standard_text.contains("logical_observable L3")); + assert!(standard_text.contains("error(0.125) D0 D1 L3")); + assert!(!standard_text.contains("TP2")); + assert!(!standard_text.contains("pecos_tracked_pauli")); + + let pecos_text = format!( + "# ordinary comments and standard DEM lines are allowed\n{}\n", + dem.to_pecos_string() + ); + let recovered = DetectorErrorModel::new() + .with_pecos_dem_metadata(&pecos_text) + .unwrap(); + assert_eq!(recovered.num_observables(), 4); + assert_eq!(recovered.num_dem_outputs(), 4); + assert_eq!(recovered.num_tracked_paulis(), 3); + assert_eq!( + recovered + .dem_outputs() + .iter() + .map(|op| (op.id, op.label.as_deref())) + .collect::>(), + [(0, Some("L0")), (3, Some("logical_aux"))] + ); + assert_eq!( + recovered + .tracked_paulis() + .iter() + .map(|op| (op.id, op.label.as_deref())) + .collect::>(), + [(0, Some("tracked_x0_z2")), (2, Some("tracked_y5"))] + ); + assert_eq!( + recovered.tracked_paulis()[0] + .pauli + .as_ref() + .unwrap() + .to_sparse_str(), + "+X0 Z2" + ); + assert_eq!( + recovered.tracked_paulis()[1] + .pauli + .as_ref() + .unwrap() + .to_sparse_str(), + "+Y5" + ); -#[cfg(test)] -mod tests { - use super::*; + let reserialized = recovered.to_pecos_string(); + assert!(reserialized.contains("logical_observable L0")); + assert!(!reserialized.contains("logical_observable L1")); + assert!(!reserialized.contains("logical_observable L2")); + assert!(reserialized.contains("logical_observable L3")); + assert!(reserialized.contains(r#""kind":"observable""#)); + assert!(reserialized.contains(r#""kind":"tracked_pauli""#)); + assert!(reserialized.contains(r#""pauli":"+X0 Z2""#)); + assert!(reserialized.contains(r#""pauli":"+Y5""#)); + assert!( + !reserialized.contains("TP2"), + "metadata-only recovery should not invent mechanism effects" + ); + } #[test] - fn test_error_mechanism_xor() { - let m1 = FaultMechanism::from_unsorted([0, 1, 2], [0]); - let m2 = FaultMechanism::from_unsorted([1, 2, 3], [0, 1]); + fn test_pecos_dem_text_and_metadata_json_preserve_same_output_metadata() { + use crate::fault_tolerance::dem_builder::ParsedDem; + use pecos_core::pauli::{X, Y, Z}; - let result = m1.xor(&m2); + let mut dem = DetectorErrorModel::new(); + dem.add_detector(DetectorDef::new(0).with_records([-1])); + dem.add_observable(DemOutput::new(0).with_records([-1]).with_label("L0")); + dem.add_observable(DemOutput::new(3).with_records([-2]).with_label("L3")); + dem.add_tracked_pauli( + DemOutput::new(0) + .with_pauli(X(0) & Z(2)) + .with_label("tracked_x0_z2"), + ); + dem.add_tracked_pauli(DemOutput::new(3).with_pauli(Y(5)).with_label("tracked_y5")); + dem.add_direct_contribution( + FaultMechanism::from_unsorted_with_tracked_paulis([0], [3], [3]), + 0.125, + ); - // Detectors: {0, 1, 2} XOR {1, 2, 3} = {0, 3} - assert_eq!(result.detectors.as_slice(), &[0, 3]); - // Logicals: {0} XOR {0, 1} = {1} - assert_eq!(result.logicals.as_slice(), &[1]); + let json_recovered = DetectorErrorModel::new() + .with_pecos_metadata_json(&dem.to_pecos_metadata_json()) + .unwrap(); + let text_recovered = DetectorErrorModel::new() + .with_pecos_dem_metadata(&dem.to_pecos_string()) + .unwrap(); + + let source_json: serde_json::Value = + serde_json::from_str(&dem.to_pecos_metadata_json()).unwrap(); + let from_json: serde_json::Value = + serde_json::from_str(&json_recovered.to_pecos_metadata_json()).unwrap(); + let from_text: serde_json::Value = + serde_json::from_str(&text_recovered.to_pecos_metadata_json()).unwrap(); + + assert_eq!(from_json, source_json); + assert_eq!(from_text, source_json); + + let parsed: ParsedDem = dem.to_pecos_string().parse().unwrap(); + assert_eq!(parsed.num_dem_outputs(), 4); + assert_eq!(parsed.num_tracked_paulis(), 4); + assert_eq!(parsed.mechanisms[0].format_targets(), "D0 L3 TP3"); + assert_eq!(parsed.mechanisms[0].components[0].observables, vec![3]); + assert_eq!(parsed.mechanisms[0].components[0].tracked_paulis, vec![3]); + assert_eq!( + parsed.dem_outputs[0].as_ref().unwrap().label.as_deref(), + Some("L0") + ); + assert_eq!( + parsed.dem_outputs[3].as_ref().unwrap().label.as_deref(), + Some("L3") + ); + assert_eq!( + parsed.tracked_paulis[0] + .as_ref() + .unwrap() + .pauli + .as_ref() + .unwrap() + .to_sparse_str(), + "+X0 Z2" + ); + assert_eq!( + parsed.tracked_paulis[3].as_ref().unwrap().label.as_deref(), + Some("tracked_y5") + ); } #[test] - fn test_error_mechanism_equality() { - let m1 = FaultMechanism::from_unsorted([2, 0, 1], [1, 0]); - let m2 = FaultMechanism::from_unsorted([0, 1, 2], [0, 1]); - - assert_eq!(m1, m2); - assert_eq!(m1.detectors.as_slice(), &[0, 1, 2]); - assert_eq!(m1.logicals.as_slice(), &[0, 1]); + fn test_pecos_dem_metadata_parser_rejects_malformed_extension_line() { + let err = DetectorErrorModel::new() + .with_pecos_dem_metadata("error(0.01) D0\npecos_tracked_pauli not-json") + .unwrap_err(); + assert!( + err.message() + .contains("invalid pecos_tracked_pauli JSON payload") + ); } #[test] - fn test_combine_probabilities() { - // Same probability twice - let p = combine_probabilities(0.01, 0.01); - // Expected: 0.01 * 0.99 + 0.01 * 0.99 = 0.0198 - assert!((p - 0.0198).abs() < 1e-10); + fn test_pecos_dem_metadata_parser_rejects_unknown_pecos_extension_line() { + let err = DetectorErrorModel::new() + .with_pecos_dem_metadata(r#"pecos_old_extension {"id":1}"#) + .unwrap_err(); - // One zero probability - assert!((combine_probabilities(0.0, 0.5) - 0.5).abs() < 1e-10); - assert!((combine_probabilities(0.5, 0.0) - 0.5).abs() < 1e-10); + assert!( + err.message() + .contains("unsupported PECOS DEM extension line") + ); + } - // Both zero - assert!((combine_probabilities(0.0, 0.0)).abs() < 1e-10); + #[test] + fn test_pecos_dem_metadata_parser_rejects_legacy_tracked_extension_line() { + let err = DetectorErrorModel::new() + .with_pecos_dem_metadata(r#"pecos_tracked_op {"id":0,"pauli":"+X0"}"#) + .unwrap_err(); + + assert!( + err.message() + .contains("unsupported PECOS DEM extension line: pecos_tracked_op") + ); } #[test] fn test_decomposed_error_single() { - let mechanism = FaultMechanism::from_unsorted([0, 1], [0]); + let mechanism = FaultMechanism::from_unsorted_with_tracked_paulis([0, 1], [0], [2]); let decomposed = DecomposedFault::single(mechanism.clone()); assert_eq!(decomposed.components.len(), 1); assert!(decomposed.is_graphlike()); assert_eq!(decomposed.full_effect(), mechanism); assert_eq!(decomposed.to_stim_targets(), "D0 D1 L0"); + assert_eq!(decomposed.to_pecos_targets(), "D0 D1 L0 TP2"); } #[test] @@ -2877,7 +5009,7 @@ mod tests { 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_observable(LogicalObservable::new(0)); + dem.add_dem_output(DemOutput::new(0)); dem.add_direct_contribution(FaultMechanism::from_unsorted([0, 1], []), 0.01); dem.add_direct_contribution(FaultMechanism::from_unsorted([0], [0]), 0.02); @@ -2928,7 +5060,8 @@ mod tests { let pair_summary = summaries .iter() .find(|summary| { - summary.effect.detectors.as_slice() == [0, 1] && summary.effect.logicals.is_empty() + summary.effect.detectors.as_slice() == [0, 1] + && summary.effect.dem_outputs.is_empty() }) .expect("pair summary missing"); assert_eq!(pair_summary.graphlike_decomposable_count, 2); @@ -2936,7 +5069,7 @@ mod tests { let singleton_summary = summaries .iter() .find(|summary| { - summary.effect.detectors.as_slice() == [0] && summary.effect.logicals.is_empty() + summary.effect.detectors.as_slice() == [0] && summary.effect.dem_outputs.is_empty() }) .expect("singleton summary missing"); assert_eq!(singleton_summary.graphlike_decomposable_count, 0); @@ -2948,7 +5081,7 @@ mod tests { 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_observable(LogicalObservable::new(0)); + dem.add_dem_output(DemOutput::new(0)); // Add contributions directly using the source tracking API dem.add_direct_contribution(FaultMechanism::from_unsorted([0, 1], []), 0.01); @@ -2964,12 +5097,12 @@ mod tests { } #[test] - fn test_dem_to_string_decomposed_keeps_two_detector_one_logical_direct() { + fn test_dem_to_string_decomposed_keeps_two_detector_one_dem_output_direct() { 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_observable(LogicalObservable::new(0)); + dem.add_dem_output(DemOutput::new(0)); dem.add_direct_contribution(FaultMechanism::from_unsorted([0, 1], [0]), 0.01); dem.add_direct_contribution(FaultMechanism::from_unsorted([0], std::iter::empty()), 0.02); @@ -2987,7 +5120,7 @@ mod tests { 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_observable(LogicalObservable::new(0)); + dem.add_dem_output(DemOutput::new(0)); let x = FaultMechanism::from_unsorted([0], std::iter::empty()); let z = FaultMechanism::from_unsorted([1], [0]); @@ -3000,7 +5133,7 @@ mod tests { } #[test] - fn test_error_mechanism_with_two_detectors_and_multiple_logicals_is_graphlike() { + fn test_error_mechanism_with_two_detectors_and_multiple_dem_outputs_is_graphlike() { let effect = FaultMechanism::from_unsorted([0, 1], [0, 1]); assert!(effect.is_graphlike()); diff --git a/crates/pecos-qec/src/fault_tolerance/fault_sampler.rs b/crates/pecos-qec/src/fault_tolerance/fault_sampler.rs new file mode 100644 index 000000000..a369a1683 --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/fault_sampler.rs @@ -0,0 +1,4587 @@ +// 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. + +//! Stochastic raw-measurement sampling via fault table overlay. +//! +//! # Architecture +//! +//! Raw measurement output = ideal measurement values XOR sampled physical faults. +//! +//! These are computed independently: +//! - **Ideal values** from [`MeasurementSampler`](pecos_simulators::measurement_sampler::MeasurementSampler), +//! which respects the Copy/Computed dependency graph from symbolic simulation. +//! Non-deterministic measurements share latent random variables through the +//! stabilizer eigenvalue structure. +//! - **Physical faults** from a fault table where each entry has a probability +//! and a set of affected measurements. Faults are sampled independently per +//! shot (Bernoulli) and XOR'd onto the ideal values. +//! +//! This separation is critical: the dependency graph captures *ideal* measurement +//! correlations (same stabilizer across resets), while fault events represent +//! *physical* noise processes (gate errors, measurement flips, prep errors). +//! Mixing them — e.g., flattening fault deps through Copy chains — incorrectly +//! cancels faults that affect only one measurement in a correlated pair. + +use pecos_core::gate_type::GateType; +use pecos_core::pauli::pauli_string::PauliString; +use pecos_core::{Pauli, QubitId}; +use pecos_quantum::{AnnotationKind, TickCircuit}; +use pecos_random::{PecosRng, RngExt}; +use pecos_simulators::measurement_sampler::{MeasurementKind, SampleResult}; +use pecos_simulators::symbolic_sparse_stab::MeasurementHistory; +use pecos_simulators::{BitmaskPauliProp, CliffordGateable}; +use std::collections::{BTreeSet, HashMap}; +use std::fmt; + +/// Error returned when `build_fault_table` encounters an unsupported gate. +#[derive(Clone, Debug)] +pub struct UnsupportedGateError { + pub gate_type: GateType, + pub tick: usize, + pub gate_in_tick: usize, + pub qubits: Vec, +} + +impl fmt::Display for UnsupportedGateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!( + f, + "Unsupported gate {:?} at tick {} gate {} on qubits {:?}. \ + Supported: H, X, Y, Z, SZ, SZdg, SX, SXdg, SY, SYdg, F, Fdg, \ + CX, CY, CZ, SXX, SXXdg, SYY, SYYdg, SZZ, SZZdg, SWAP, \ + MZ/MeasureFree/MeasureLeaked, PZ, QAlloc, QFree, I, Idle, \ + plus metadata (MeasCrosstalk*, TrackedPauliMeta).", + self.gate_type, self.tick, self.gate_in_tick, self.qubits + ) + } +} + +impl std::error::Error for UnsupportedGateError {} + +/// Standard single-qubit Clifford gates supported by `CliffordGateable`. +pub const STANDARD_1Q_CLIFFORD_GATES: &[GateType] = &[ + GateType::X, + GateType::Y, + GateType::Z, + GateType::H, + GateType::SZ, + GateType::SZdg, + GateType::SX, + GateType::SXdg, + GateType::SY, + GateType::SYdg, + GateType::F, + GateType::Fdg, +]; + +/// Standard two-qubit Clifford gates supported by `CliffordGateable`. +pub const STANDARD_2Q_CLIFFORD_GATES: &[GateType] = &[ + GateType::CX, + GateType::CY, + GateType::CZ, + GateType::SXX, + GateType::SXXdg, + GateType::SYY, + GateType::SYYdg, + GateType::SZZ, + GateType::SZZdg, + GateType::SWAP, +]; + +#[inline] +fn is_standard_1q_clifford_gate(gate_type: GateType) -> bool { + STANDARD_1Q_CLIFFORD_GATES.contains(&gate_type) +} + +#[inline] +fn is_standard_2q_clifford_gate(gate_type: GateType) -> bool { + STANDARD_2Q_CLIFFORD_GATES.contains(&gate_type) +} + +#[inline] +fn is_supported_measurement_gate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + ) +} + +#[inline] +fn is_supported_prep_gate(gate_type: GateType) -> bool { + matches!(gate_type, GateType::PZ | GateType::QAlloc) +} + +#[inline] +fn is_supported_noop_or_metadata_gate(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::QFree + | GateType::I + | GateType::Idle + | GateType::MeasCrosstalkGlobalPayload + | GateType::MeasCrosstalkLocalPayload + | GateType::TrackedPauliMeta + ) +} + +/// A fault mechanism: fires with probability `p`, then uniformly selects one +/// of its alternatives to determine which measurements are flipped. +/// +/// For a depolarizing channel with k non-identity Paulis and total error +/// probability p: the mechanism fires with probability p, then each of the +/// k alternatives is chosen with probability 1/k. This matches the stabilizer +/// sim's "exactly one Pauli error per gate event" semantics. +#[derive(Clone, Debug, PartialEq)] +pub struct FaultMechanism { + /// Total probability that this mechanism fires (one Bernoulli per shot). + pub probability: f64, + /// Each alternative is a set of measurements that get flipped if that + /// alternative is selected. Empty alternatives (no measurements flipped) + /// are preserved — they represent Pauli errors that commute with all + /// subsequent measurements (e.g., Z after MZ). Keeping them maintains + /// the correct 1/k uniform denominator for the depolarizing channel. + pub alternatives: Vec>, +} + +/// Noise parameters for depolarizing fault injection. +#[derive(Clone, Debug)] +pub struct StochasticNoiseParams { + pub p1: f64, + pub p2: f64, + pub p_meas: f64, + pub p_prep: f64, +} + +/// A gate in the flattened gate list (one entry per qubit-pair or single qubit). +#[derive(Clone, Debug)] +pub(crate) struct GateLoc { + pub(crate) tick: usize, + pub(crate) gate_index: usize, + pub(crate) gate_type: GateType, + pub(crate) qubits: Vec, +} + +/// Single-qubit Pauli type for fault injection. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) enum PauliType { + X, + Y, + Z, +} + +/// Build a fault table from a `TickCircuit` and noise parameters. +/// +/// Each entry describes one possible fault mechanism: its probability and +/// which measurements it would flip if it occurs. The table is used for +/// independent per-shot Bernoulli sampling. +/// +/// Gate ordering follows the `TickCircuit` tick-by-tick structure, which must +/// match the measurement numbering used by detector/DEM-output record indices. +/// +/// # Supported gates +/// +/// **Fault injection** (noise applied after these gates): +/// - Single-qubit Clifford: `H`, `X`, `Y`, `Z`, `SZ`, `SZdg`, `SX`, `SXdg`, +/// `SY`, `SYdg`, `F`, `Fdg` → `p=p1`, 3 alternatives +/// - Two-qubit Clifford: `CX`, `CY`, `CZ`, `SXX`, `SXXdg`, `SYY`, `SYYdg`, +/// `SZZ`, `SZZdg`, `SWAP` → `p=p2`, 15 alternatives +/// - State preparation: `PZ`, `QAlloc` → mechanism with `p=p_prep`, 1 alternative (`X`) +/// - Measurement: `MZ`, `MeasureFree`, `MeasureLeaked` → mechanism with +/// `p=p_meas`, 1 alternative (flip) +/// +/// Each mechanism fires at most once per shot (Bernoulli with total probability p). +/// When it fires, exactly one alternative is chosen uniformly at random. This +/// matches the depolarizing channel semantics: "with probability p, apply one +/// of the k non-identity Paulis, each equally likely." +/// +/// **Propagation** (gates that transform a propagating Pauli): +/// - All single-qubit Cliffords: Clifford conjugation via direct Pauli-basis updates +/// - All two-qubit Cliffords: Clifford conjugation via direct Pauli-basis updates +/// - `PZ`, `QAlloc`: absorbs all Pauli components on the reset qubit +/// - `MZ`: records `X`-component flip, then absorbs all components (state collapse) +/// +/// **No-op** (pass through without noise or transformation): +/// - `I`, `Idle`, `QFree`, `MeasCrosstalkGlobalPayload`, +/// `MeasCrosstalkLocalPayload`, `TrackedPauliMeta` +/// +/// Any gate not in the above lists returns [`UnsupportedGateError`]. +/// +/// # Errors +/// +/// Returns [`UnsupportedGateError`] when the circuit contains a gate outside +/// the supported Clifford/prep/measurement/metadata set. +pub fn build_fault_table( + tc: &TickCircuit, + noise: &StochasticNoiseParams, +) -> Result, UnsupportedGateError> { + let mut catalog = FaultCatalog::from_circuit(tc)?; + catalog.with_noise(noise); + Ok(catalog.to_mechanisms()) +} + +/// Validate that all gates in the `TickCircuit` are supported (before flattening). +fn validate_tick_circuit(tc: &TickCircuit) -> Result<(), UnsupportedGateError> { + for (tick_idx, tick) in tc.iter_ticks() { + for gate in tick.iter_gate_batches() { + if is_standard_1q_clifford_gate(gate.gate_type) + || is_standard_2q_clifford_gate(gate.gate_type) + || is_supported_measurement_gate(gate.gate_type) + || is_supported_prep_gate(gate.gate_type) + || is_supported_noop_or_metadata_gate(gate.gate_type) + { + continue; + } + return Err(UnsupportedGateError { + gate_type: gate.gate_type, + tick: tick_idx, + gate_in_tick: gate.batch_index(), + qubits: gate.qubits.iter().map(pecos_core::QubitId::index).collect(), + }); + } + } + Ok(()) +} + +/// Flatten a `TickCircuit` into individual gate applications with measurement +/// position tracking. +/// +/// Stored batches are expanded through `TickCircuit`'s `GateInstanceRef` +/// iterator so the qubit/measurement-id slicing semantics are shared with +/// other consumers. Each measurement and each multi-qubit pair gets its own +/// position for fault injection. Returns the gate list and a map from gate-list +/// index to measurement index. +pub(crate) fn flatten_tick_circuit(tc: &TickCircuit) -> (Vec, HashMap) { + let mut gates = Vec::new(); + let mut meas_positions = HashMap::new(); + let mut meas_count = 0usize; + + for (tick_idx, tick) in tc.iter_ticks() { + for gate in tick.iter_gate_instances() { + let qs: Vec = gate + .qubits() + .iter() + .map(pecos_core::QubitId::index) + .collect(); + if is_supported_measurement_gate(gate.gate_type()) { + meas_positions.insert(gates.len(), meas_count); + meas_count += 1; + } + gates.push(GateLoc { + tick: tick_idx, + gate_index: gate.batch_index(), + gate_type: gate.gate_type(), + qubits: qs, + }); + } + } + + (gates, meas_positions) +} + +/// Propagate a single-qubit Pauli fault forward through the gate list. +/// +/// Returns the set of measurement indices whose outcomes would be flipped +/// by this Pauli error at this position. +#[cfg(test)] +pub(crate) fn propagate_single( + pauli: PauliType, + qubit: usize, + start: usize, + gates: &[GateLoc], + meas_positions: &HashMap, +) -> BTreeSet { + let mut prop = BitmaskPauliProp::new(); + match pauli { + PauliType::X => prop.track_x(&[qubit]), + PauliType::Y => prop.track_y(&[qubit]), + PauliType::Z => prop.track_z(&[qubit]), + } + + propagate_forward(&mut prop, start, gates, meas_positions) +} + +fn propagate_single_effect( + pauli: PauliType, + qubit: usize, + start: usize, + gates: &[GateLoc], + meas_positions: &HashMap, + tracked_paulis: &[PauliString], +) -> PropagatedFaultEffect { + let mut prop = BitmaskPauliProp::new(); + match pauli { + PauliType::X => prop.track_x(&[qubit]), + PauliType::Y => prop.track_y(&[qubit]), + PauliType::Z => prop.track_z(&[qubit]), + } + + let affected_measurements = propagate_forward(&mut prop, start, gates, meas_positions); + let affected_tracked_paulis = tracked_paulis_flipped_by(&prop, tracked_paulis); + PropagatedFaultEffect { + affected_measurements, + affected_tracked_paulis, + } +} + +#[cfg(test)] +fn propagate_pair_effect( + faults: [(PauliType, usize); 2], + start: usize, + gates: &[GateLoc], + meas_positions: &HashMap, + tracked_paulis: &[PauliString], +) -> PropagatedFaultEffect { + let mut prop = BitmaskPauliProp::new(); + for (pauli, qubit) in faults { + match pauli { + PauliType::X => prop.track_x(&[qubit]), + PauliType::Y => prop.track_y(&[qubit]), + PauliType::Z => prop.track_z(&[qubit]), + } + } + + let affected_measurements = propagate_forward(&mut prop, start, gates, meas_positions); + let affected_tracked_paulis = tracked_paulis_flipped_by(&prop, tracked_paulis); + PropagatedFaultEffect { + affected_measurements, + affected_tracked_paulis, + } +} + +#[derive(Clone, Debug, PartialEq, Eq)] +struct PropagatedFaultEffect { + affected_measurements: BTreeSet, + affected_tracked_paulis: Vec, +} + +#[derive(Default)] +struct PropagatedEffectCache { + singles: HashMap<(usize, PauliType, usize), PropagatedFaultEffect>, +} + +impl PropagatedEffectCache { + #[cfg(test)] + fn len(&self) -> usize { + self.singles.len() + } + + fn single( + &mut self, + pauli: PauliType, + qubit: usize, + start: usize, + gates: &[GateLoc], + meas_positions: &HashMap, + tracked_paulis: &[PauliString], + ) -> PropagatedFaultEffect { + self.singles + .entry((start, pauli, qubit)) + .or_insert_with(|| { + propagate_single_effect(pauli, qubit, start, gates, meas_positions, tracked_paulis) + }) + .clone() + } +} + +fn xor_fault_effects( + left: &PropagatedFaultEffect, + right: &PropagatedFaultEffect, +) -> PropagatedFaultEffect { + let mut affected_measurements = left.affected_measurements.clone(); + for &measurement in &right.affected_measurements { + if !affected_measurements.remove(&measurement) { + affected_measurements.insert(measurement); + } + } + + PropagatedFaultEffect { + affected_measurements, + affected_tracked_paulis: xor_sorted_unique_indices( + &left.affected_tracked_paulis, + &right.affected_tracked_paulis, + ), + } +} + +fn xor_sorted_unique_indices(left: &[usize], right: &[usize]) -> Vec { + let mut out = Vec::with_capacity(left.len() + right.len()); + let mut i = 0usize; + let mut j = 0usize; + while i < left.len() && j < right.len() { + match left[i].cmp(&right[j]) { + std::cmp::Ordering::Less => { + out.push(left[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + out.push(right[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + i += 1; + j += 1; + } + } + } + out.extend_from_slice(&left[i..]); + out.extend_from_slice(&right[j..]); + out +} + +/// Core forward propagation: evolve a Pauli through gates, collecting affected measurements. +fn propagate_forward( + prop: &mut BitmaskPauliProp, + start: usize, + gates: &[GateLoc], + meas_positions: &HashMap, +) -> BTreeSet { + let mut affected = BTreeSet::new(); + + for (loc_idx, loc) in gates.iter().enumerate().skip(start) { + match loc.gate_type { + GateType::H if !loc.qubits.is_empty() => { + prop.h(&[QubitId(loc.qubits[0])]); + } + GateType::SZ if !loc.qubits.is_empty() => { + prop.sz(&[QubitId(loc.qubits[0])]); + } + GateType::SZdg if !loc.qubits.is_empty() => { + let q = QubitId(loc.qubits[0]); + prop.szdg(&[q]); + } + GateType::SX if !loc.qubits.is_empty() => { + prop.sx(&[QubitId(loc.qubits[0])]); + } + GateType::SXdg if !loc.qubits.is_empty() => { + prop.sxdg(&[QubitId(loc.qubits[0])]); + } + GateType::SY if !loc.qubits.is_empty() => { + prop.sy(&[QubitId(loc.qubits[0])]); + } + GateType::SYdg if !loc.qubits.is_empty() => { + prop.sydg(&[QubitId(loc.qubits[0])]); + } + GateType::F if !loc.qubits.is_empty() => { + prop.f(&[QubitId(loc.qubits[0])]); + } + GateType::Fdg if !loc.qubits.is_empty() => { + prop.fdg(&[QubitId(loc.qubits[0])]); + } + GateType::CX if loc.qubits.len() >= 2 => { + prop.cx(&[(QubitId(loc.qubits[0]), QubitId(loc.qubits[1]))]); + } + GateType::CY if loc.qubits.len() >= 2 => { + let (q1, q2) = (QubitId(loc.qubits[0]), QubitId(loc.qubits[1])); + prop.cy(&[(q1, q2)]); + } + GateType::CZ if loc.qubits.len() >= 2 => { + let (q1, q2) = (QubitId(loc.qubits[0]), QubitId(loc.qubits[1])); + prop.cz(&[(q1, q2)]); + } + GateType::SXX if loc.qubits.len() >= 2 => { + let pair = [(QubitId(loc.qubits[0]), QubitId(loc.qubits[1]))]; + prop.sxx(&pair); + } + GateType::SXXdg if loc.qubits.len() >= 2 => { + let pair = [(QubitId(loc.qubits[0]), QubitId(loc.qubits[1]))]; + prop.sxxdg(&pair); + } + GateType::SYY if loc.qubits.len() >= 2 => { + let pair = [(QubitId(loc.qubits[0]), QubitId(loc.qubits[1]))]; + prop.syy(&pair); + } + GateType::SYYdg if loc.qubits.len() >= 2 => { + let pair = [(QubitId(loc.qubits[0]), QubitId(loc.qubits[1]))]; + prop.syydg(&pair); + } + GateType::SZZ if loc.qubits.len() >= 2 => { + let pair = [(QubitId(loc.qubits[0]), QubitId(loc.qubits[1]))]; + prop.szz(&pair); + } + GateType::SZZdg if loc.qubits.len() >= 2 => { + let pair = [(QubitId(loc.qubits[0]), QubitId(loc.qubits[1]))]; + prop.szzdg(&pair); + } + GateType::SWAP if loc.qubits.len() >= 2 => { + let pair = [(QubitId(loc.qubits[0]), QubitId(loc.qubits[1]))]; + prop.swap(&pair); + } + // PZ/QAlloc absorbs propagating errors on the reset qubit + GateType::PZ | GateType::QAlloc if !loc.qubits.is_empty() => { + prop.clear_qubit(loc.qubits[0]); + } + // MZ: X component flips the measurement, then qubit state collapses + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + if !loc.qubits.is_empty() => + { + let q = loc.qubits[0]; + if prop.contains_x(q) + && let Some(&meas_idx) = meas_positions.get(&loc_idx) + { + affected.insert(meas_idx); + } + prop.clear_qubit(q); + } + _ => {} + } + + if prop.is_identity() { + break; + } + } + + affected +} + +// ============================================================================ +// Fault Catalog: per-location, per-alternative lookup table +// ============================================================================ + +/// The kind of physical fault mechanism. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum FaultKind { + /// A Pauli error injected after a gate. + Pauli, + /// A measurement outcome flip. + MeasurementFlip, + /// A preparation error (X on |0⟩). + PrepFlip, +} + +/// Which noise channel produced this fault location. +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub enum FaultChannel { + /// Single-qubit depolarizing (`p1`). + P1, + /// Two-qubit depolarizing (`p2`). + P2, + /// Measurement flip (`p_meas`). + PMeas, + /// State preparation flip (`p_prep`). + PPrep, +} + +/// One alternative within a physical fault location. +#[derive(Clone, Debug)] +pub struct FaultAlternative { + /// Kind of fault. + pub kind: FaultKind, + /// The Pauli error for this alternative (None for measurement/prep faults). + pub pauli: Option, + /// Raw measurement indices flipped by this fault. + pub affected_measurements: Vec, + /// Detector indices flipped (computed from measurement effects + detector records). + pub affected_detectors: Vec, + /// Observable indices flipped. + pub affected_observables: Vec, + /// Tracked-Pauli indices flipped. + pub affected_tracked_paulis: Vec, + /// Probability of this alternative conditioned on the mechanism firing (`1/k`). + pub conditional_probability: f64, + /// Marginal probability of this specific alternative at this location: `p_i / k_i`. + /// + /// This is NOT "probability of this fault and no others." A full-circuit + /// configuration probability requires multiplying by `(1 - p_j)` for all + /// other locations `j`. + pub absolute_probability: f64, +} + +/// A physical fault location in the circuit. +#[derive(Clone, Debug)] +pub struct FaultLocation { + /// Tick index in the `TickCircuit`. + pub tick: usize, + /// Gate index within the tick. + pub gate_index: usize, + /// Gate type at this location. + pub gate_type: GateType, + /// Qubits involved. + pub qubits: Vec, + /// Which noise channel this location belongs to. + pub channel: FaultChannel, + /// Total probability that this mechanism fires: `p_i`. + pub channel_probability: f64, + /// Probability that no fault occurs at this location: `1 - p_i`. + pub no_fault_probability: f64, + /// Number of fault alternatives at this location: `k_i`. + pub num_alternatives: usize, + /// All fault alternatives at this location. + pub faults: Vec, +} + +/// Complete fault catalog for a circuit + noise model. +/// +/// Each location is an independent physical fault mechanism. +/// Each alternative within a location is one possible Pauli error +/// (for depolarizing) or outcome flip (for measurement/prep). +/// +/// Probability model (independent mechanisms): +/// +/// For location `i` with `k_i` alternatives: +/// - `channel_probability` = `p_i` (total probability mechanism fires) +/// - `no_fault_probability` = `1 - p_i` +/// - `conditional_probability` = `1/k_i` (uniform alternative choice) +/// - `absolute_probability` = `p_i / k_i` (marginal alternative probability) +/// +/// Full-circuit configuration probability for "alternative j at location i, +/// no fault at all other locations": +/// ```text +/// P = (p_i / k_i) * product_{m != i} (1 - p_m) +/// ``` +#[derive(Clone, Debug)] +pub struct FaultCatalog { + pub locations: Vec, +} + +/// One yielded configuration from `fault_configurations(k)`. +#[derive(Clone, Debug)] +pub struct FaultConfiguration { + /// Indices into `catalog.locations` for the k selected locations. + pub location_indices: Vec, + /// Alternative index chosen within each selected location. + pub alternative_indices: Vec, + /// Combined measurement indices (XOR parity across selected alternatives). + pub affected_measurements: Vec, + /// Combined detector indices (XOR parity). + pub affected_detectors: Vec, + /// Combined observable indices (XOR parity). + pub affected_observables: Vec, + /// Combined tracked-Pauli indices (XOR parity). + pub affected_tracked_paulis: Vec, + /// Product of selected alternatives' `absolute_probability`. + pub selected_probability: f64, + /// `selected_probability * product(unselected no_fault_probability)`. + pub configuration_probability: f64, +} + +impl FaultCatalog { + /// Build a structural fault catalog from a circuit. + /// + /// The returned catalog includes all structurally supported noisy locations, + /// independent of any concrete noise point. All channel and alternative + /// probabilities are initialized to zero except `no_fault_probability`, which + /// is initialized to one. + /// + /// # Errors + /// + /// Returns [`UnsupportedGateError`] when the circuit contains a gate outside + /// the supported Clifford/prep/measurement/metadata set. + pub fn from_circuit(tc: &TickCircuit) -> Result { + build_structural_fault_catalog(tc) + } + + /// Recompute noise-dependent probability fields for this catalog. + /// + /// This updates only `channel_probability`, `no_fault_probability`, and + /// `absolute_probability`. Structural fields such as `num_alternatives`, + /// `conditional_probability`, Pauli labels, and effect lists are unchanged. + /// + /// # Panics + /// + /// Panics if a malformed catalog contains more than `u32::MAX` alternatives + /// at one location. Catalogs produced by [`FaultCatalog::from_circuit`] have + /// at most 15 alternatives per location. + pub fn with_noise(&mut self, noise: &StochasticNoiseParams) -> &mut Self { + for loc in &mut self.locations { + let p = match loc.channel { + FaultChannel::P1 => noise.p1, + FaultChannel::P2 => noise.p2, + FaultChannel::PMeas => noise.p_meas, + FaultChannel::PPrep => noise.p_prep, + }; + let k = loc.num_alternatives; + debug_assert!(k > 0, "fault location has no alternatives"); + debug_assert_eq!(k, loc.faults.len(), "num_alternatives out of sync"); + let k_f64 = f64::from(u32::try_from(k).expect("fault alternative count exceeds u32")); + + loc.channel_probability = p; + loc.no_fault_probability = 1.0 - p; + for alt in &mut loc.faults { + alt.absolute_probability = p / k_f64; + } + } + self + } + + /// Clone this catalog and apply a concrete noise point to the clone. + #[must_use] + pub fn parameterized(&self, noise: &StochasticNoiseParams) -> Self { + let mut copy = self.clone(); + copy.with_noise(noise); + copy + } + + /// Convert this catalog into raw-measurement sampling mechanisms. + /// + /// This is a materialization step for raw measurement sampling only. It + /// drops zero-probability locations and locations where every alternative has + /// empty `affected_measurements`, while preserving empty alternatives inside + /// any kept mechanism to maintain the correct uniform denominator. + #[must_use] + pub fn to_mechanisms(&self) -> Vec { + self.locations + .iter() + .filter(|loc| loc.channel_probability > 0.0) + .filter(|loc| { + loc.faults + .iter() + .any(|alt| !alt.affected_measurements.is_empty()) + }) + .map(|loc| FaultMechanism { + probability: loc.channel_probability, + alternatives: loc + .faults + .iter() + .map(|alt| alt.affected_measurements.clone()) + .collect(), + }) + .collect() + } + + /// Lazily iterate all k-fault configurations. + /// + /// Each yielded `FaultConfiguration` represents exactly k distinct locations + /// firing, with one alternative chosen per location. Effects are combined by + /// XOR parity. Probabilities follow the independent-mechanism model. + /// + /// Zero-probability alternatives are skipped. A structural location with + /// `channel_probability == 0` remains in [`FaultCatalog::locations`] but is + /// not yielded as a selected fault configuration. + /// + /// For k=0: yields one no-fault event. + #[must_use] + pub fn fault_configurations(&self, k: usize) -> FaultConfigurationIter<'_> { + FaultConfigurationIter::new(self, k) + } +} + +/// Internal cursor for k-fault configuration iteration. +/// +/// Holds the combination/alternative state machine. Shared by both +/// `FaultConfigurationIter` (borrowed) and `OwnedFaultConfigIter` (owned). +/// Combinations range over nonzero-probability fault alternatives only; the +/// full structural catalog remains available through `FaultCatalog::locations`. +struct FaultConfigCursor { + k: usize, + location_indices: Vec, + alternative_indices: Vec>, + combo: Vec, + alt_indices: Vec, + alt_counts: Vec, + started: bool, + done: bool, +} + +impl FaultConfigCursor { + fn new(catalog: &FaultCatalog, k: usize) -> Self { + let mut location_indices = Vec::new(); + let mut alternative_indices = Vec::new(); + for (loc_idx, loc) in catalog.locations.iter().enumerate() { + let alts: Vec = loc + .faults + .iter() + .enumerate() + .filter_map(|(alt_idx, alt)| (alt.absolute_probability > 0.0).then_some(alt_idx)) + .collect(); + if !alts.is_empty() { + location_indices.push(loc_idx); + alternative_indices.push(alts); + } + } + + let num_active_locations = location_indices.len(); + if k == 0 || k > num_active_locations { + return Self { + k, + location_indices, + alternative_indices, + combo: Vec::new(), + alt_indices: Vec::new(), + alt_counts: Vec::new(), + started: false, + done: k > num_active_locations && k > 0, + }; + } + let combo: Vec = (0..k).collect(); + let alt_counts: Vec = combo + .iter() + .map(|&i| alternative_indices[i].len()) + .collect(); + let alt_indices = vec![0usize; k]; + Self { + k, + location_indices, + alternative_indices, + combo, + alt_indices, + alt_counts, + started: false, + done: false, + } + } + + /// Advance to the next state. Returns true if a new valid state exists. + fn advance(&mut self) -> bool { + // Try advancing alternatives (mixed-radix counter) + for i in (0..self.k).rev() { + self.alt_indices[i] += 1; + if self.alt_indices[i] < self.alt_counts[i] { + return true; + } + self.alt_indices[i] = 0; + } + // Try advancing combination + let mut i = self.k; + while i > 0 { + i -= 1; + self.combo[i] += 1; + if self.combo[i] <= self.location_indices.len() - self.k + i { + for j in (i + 1)..self.k { + self.combo[j] = self.combo[j - 1] + 1; + } + for j in 0..self.k { + self.alt_counts[j] = self.alternative_indices[self.combo[j]].len(); + self.alt_indices[j] = 0; + } + return true; + } + } + false + } + + /// Build a `FaultConfiguration` from the current cursor state + catalog data. + fn build(&self, catalog: &FaultCatalog) -> FaultConfiguration { + if self.k == 0 { + let no_fault_prob: f64 = catalog + .locations + .iter() + .map(|l| l.no_fault_probability) + .product(); + return FaultConfiguration { + location_indices: Vec::new(), + alternative_indices: Vec::new(), + affected_measurements: Vec::new(), + affected_detectors: Vec::new(), + affected_observables: Vec::new(), + affected_tracked_paulis: Vec::new(), + selected_probability: 1.0, + configuration_probability: no_fault_prob, + }; + } + + let mut meas_set = std::collections::BTreeSet::new(); + let mut det_set = std::collections::BTreeSet::new(); + let mut obs_set = std::collections::BTreeSet::new(); + let mut tracked_pauli_set = std::collections::BTreeSet::new(); + let mut selected_prob = 1.0; + + for i in 0..self.k { + let location_index = self.location_indices[self.combo[i]]; + let alternative_index = self.alternative_indices[self.combo[i]][self.alt_indices[i]]; + let loc = &catalog.locations[location_index]; + let alt = &loc.faults[alternative_index]; + selected_prob *= alt.absolute_probability; + for &m in &alt.affected_measurements { + if !meas_set.remove(&m) { + meas_set.insert(m); + } + } + for &d in &alt.affected_detectors { + if !det_set.remove(&d) { + det_set.insert(d); + } + } + for &o in &alt.affected_observables { + if !obs_set.remove(&o) { + obs_set.insert(o); + } + } + for &op in &alt.affected_tracked_paulis { + if !tracked_pauli_set.remove(&op) { + tracked_pauli_set.insert(op); + } + } + } + + let selected_set: std::collections::BTreeSet = self + .combo + .iter() + .map(|&i| self.location_indices[i]) + .collect(); + let unselected_no_fault: f64 = catalog + .locations + .iter() + .enumerate() + .filter(|(i, _)| !selected_set.contains(i)) + .map(|(_, loc)| loc.no_fault_probability) + .product(); + + FaultConfiguration { + location_indices: self + .combo + .iter() + .map(|&i| self.location_indices[i]) + .collect(), + alternative_indices: self + .combo + .iter() + .zip(self.alt_indices.iter()) + .map(|(&loc_pos, &alt_pos)| self.alternative_indices[loc_pos][alt_pos]) + .collect(), + affected_measurements: meas_set.into_iter().collect(), + affected_detectors: det_set.into_iter().collect(), + affected_observables: obs_set.into_iter().collect(), + affected_tracked_paulis: tracked_pauli_set.into_iter().collect(), + selected_probability: selected_prob, + configuration_probability: selected_prob * unselected_no_fault, + } + } + + /// Drive the iterator: yield next configuration or None. + fn next_config(&mut self, catalog: &FaultCatalog) -> Option { + if self.done { + return None; + } + if self.k == 0 { + self.done = true; + return Some(self.build(catalog)); + } + if !self.started { + self.started = true; + return Some(self.build(catalog)); + } + if self.advance() { + Some(self.build(catalog)) + } else { + self.done = true; + None + } + } +} + +/// Lazy iterator over k-fault configurations (borrows catalog). +pub struct FaultConfigurationIter<'a> { + catalog: &'a FaultCatalog, + cursor: FaultConfigCursor, +} + +impl<'a> FaultConfigurationIter<'a> { + fn new(catalog: &'a FaultCatalog, k: usize) -> Self { + let cursor = FaultConfigCursor::new(catalog, k); + Self { catalog, cursor } + } +} + +impl Iterator for FaultConfigurationIter<'_> { + type Item = FaultConfiguration; + fn next(&mut self) -> Option { + self.cursor.next_config(self.catalog) + } +} + +/// Owned k-fault configuration iterator (no lifetime borrows). +/// Suitable for FFI / `PyO3` where lifetimes are not expressible. +pub struct OwnedFaultConfigIter { + catalog: FaultCatalog, + cursor: FaultConfigCursor, +} + +impl OwnedFaultConfigIter { + /// Create from an owned catalog clone. + #[must_use] + pub fn new(catalog: FaultCatalog, k: usize) -> Self { + let cursor = FaultConfigCursor::new(&catalog, k); + Self { catalog, cursor } + } +} + +impl Iterator for OwnedFaultConfigIter { + type Item = FaultConfiguration; + fn next(&mut self) -> Option { + self.cursor.next_config(&self.catalog) + } +} + +/// Build a fault catalog from a `TickCircuit` and noise parameters. +/// +/// Returns per-location, per-alternative fault data including Pauli labels, +/// affected detectors, observables, tracked Paulis, and probability fields. +/// +/// Reads detector/observable metadata and tracked-Pauli annotations +/// from the circuit when present. +/// +/// # Errors +/// +/// Returns [`UnsupportedGateError`] when the circuit contains a gate outside +/// the supported Clifford/prep/measurement/metadata set. +pub fn build_fault_catalog( + tc: &TickCircuit, + noise: &StochasticNoiseParams, +) -> Result { + let mut catalog = FaultCatalog::from_circuit(tc)?; + catalog.with_noise(noise); + Ok(catalog) +} + +fn build_structural_fault_catalog(tc: &TickCircuit) -> Result { + validate_tick_circuit(tc)?; + let (gates, meas_positions) = flatten_tick_circuit(tc); + + // Parse detector/DEM-output records for measurement→detector/op mapping + let det_records = parse_detector_records(tc); + let obs_records = parse_observable_records(tc); + let tracked_pauli_annotations = parse_tracked_pauli_annotations(tc); + let num_meas = tc + .get_meta("num_measurements") + .and_then(|a| { + if let pecos_quantum::Attribute::String(s) = a { + s.parse::().ok() + } else { + None + } + }) + .unwrap_or(meas_positions.len()); + let record_effect_index = RecordEffectIndex::new(&det_records, &obs_records, num_meas); + + let mut locations = Vec::new(); + + let pauli_types = [PauliType::X, PauliType::Y, PauliType::Z]; + let mut effect_cache = PropagatedEffectCache::default(); + + for (loc_idx, loc) in gates.iter().enumerate() { + let tick_idx = loc.tick; + let gate_idx = loc.gate_index; + let gate_type = loc.gate_type; + let qubits = &loc.qubits; + + match gate_type { + gate_type if is_standard_1q_clifford_gate(gate_type) && !loc.qubits.is_empty() => { + let q = loc.qubits[0]; + let num_alts = 3; + let conditional_probability = 1.0 / 3.0; + let mut faults = Vec::with_capacity(num_alts); + for &pt in &pauli_types { + let effect = effect_cache.single( + pt, + q, + loc_idx + 1, + &gates, + &meas_positions, + &tracked_pauli_annotations, + ); + let pauli = pauli_type_to_string(pt, q); + let (affected, dets, obs, tracked) = + catalog_effect_parts(effect, &record_effect_index); + faults.push(FaultAlternative { + kind: FaultKind::Pauli, + pauli: Some(pauli), + affected_measurements: affected, + affected_detectors: dets, + affected_observables: obs, + affected_tracked_paulis: tracked, + conditional_probability, + absolute_probability: 0.0, + }); + } + let num_alts = faults.len(); + locations.push(FaultLocation { + tick: tick_idx, + gate_index: gate_idx, + gate_type, + qubits: qubits.clone(), + channel: FaultChannel::P1, + channel_probability: 0.0, + no_fault_probability: 1.0, + num_alternatives: num_alts, + faults, + }); + } + + gate_type if is_standard_2q_clifford_gate(gate_type) && loc.qubits.len() >= 2 => { + let (q1, q2) = (loc.qubits[0], loc.qubits[1]); + let num_alts = 15; + let conditional_probability = 1.0 / 15.0; + let mut faults = Vec::with_capacity(num_alts); + + // 9 two-qubit pairs + for &p1 in &pauli_types { + for &p2 in &pauli_types { + let left = effect_cache.single( + p1, + q1, + loc_idx + 1, + &gates, + &meas_positions, + &tracked_pauli_annotations, + ); + let right = effect_cache.single( + p2, + q2, + loc_idx + 1, + &gates, + &meas_positions, + &tracked_pauli_annotations, + ); + let effect = xor_fault_effects(&left, &right); + let pauli = pauli_pair_to_string(p1, q1, p2, q2); + let (affected, dets, obs, tracked) = + catalog_effect_parts(effect, &record_effect_index); + faults.push(FaultAlternative { + kind: FaultKind::Pauli, + pauli: Some(pauli), + affected_measurements: affected, + affected_detectors: dets, + affected_observables: obs, + affected_tracked_paulis: tracked, + conditional_probability, + absolute_probability: 0.0, + }); + } + } + // 6 single-qubit (PI and IP) + for &p in &pauli_types { + let effect = effect_cache.single( + p, + q1, + loc_idx + 1, + &gates, + &meas_positions, + &tracked_pauli_annotations, + ); + let pauli = pauli_type_to_string(p, q1); + let (affected, dets, obs, tracked) = + catalog_effect_parts(effect, &record_effect_index); + faults.push(FaultAlternative { + kind: FaultKind::Pauli, + pauli: Some(pauli), + affected_measurements: affected, + affected_detectors: dets, + affected_observables: obs, + affected_tracked_paulis: tracked, + conditional_probability, + absolute_probability: 0.0, + }); + + let effect = effect_cache.single( + p, + q2, + loc_idx + 1, + &gates, + &meas_positions, + &tracked_pauli_annotations, + ); + let pauli = pauli_type_to_string(p, q2); + let (affected, dets, obs, tracked) = + catalog_effect_parts(effect, &record_effect_index); + faults.push(FaultAlternative { + kind: FaultKind::Pauli, + pauli: Some(pauli), + affected_measurements: affected, + affected_detectors: dets, + affected_observables: obs, + affected_tracked_paulis: tracked, + conditional_probability, + absolute_probability: 0.0, + }); + } + let n_alts = faults.len(); + locations.push(FaultLocation { + tick: tick_idx, + gate_index: gate_idx, + gate_type, + qubits: qubits.clone(), + channel: FaultChannel::P2, + channel_probability: 0.0, + no_fault_probability: 1.0, + num_alternatives: n_alts, + faults, + }); + } + + GateType::PZ | GateType::QAlloc if !loc.qubits.is_empty() => { + let q = loc.qubits[0]; + let effect = effect_cache.single( + PauliType::X, + q, + loc_idx + 1, + &gates, + &meas_positions, + &tracked_pauli_annotations, + ); + let (affected, dets, obs, tracked) = + catalog_effect_parts(effect, &record_effect_index); + locations.push(FaultLocation { + tick: tick_idx, + gate_index: gate_idx, + gate_type, + qubits: qubits.clone(), + channel: FaultChannel::PPrep, + channel_probability: 0.0, + no_fault_probability: 1.0, + num_alternatives: 1, + faults: vec![FaultAlternative { + kind: FaultKind::PrepFlip, + pauli: None, + affected_measurements: affected, + affected_detectors: dets, + affected_observables: obs, + affected_tracked_paulis: tracked, + conditional_probability: 1.0, + absolute_probability: 0.0, + }], + }); + } + + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked => { + if let Some(&meas_idx) = meas_positions.get(&loc_idx) { + let affected = vec![meas_idx]; + let dets = record_effect_index.detectors_for_measurements(&affected); + let obs = record_effect_index.observables_for_measurements(&affected); + locations.push(FaultLocation { + tick: tick_idx, + gate_index: gate_idx, + gate_type, + qubits: qubits.clone(), + channel: FaultChannel::PMeas, + channel_probability: 0.0, + no_fault_probability: 1.0, + num_alternatives: 1, + faults: vec![FaultAlternative { + kind: FaultKind::MeasurementFlip, + pauli: None, + affected_measurements: affected, + affected_detectors: dets, + affected_observables: obs, + affected_tracked_paulis: Vec::new(), + conditional_probability: 1.0, + absolute_probability: 0.0, + }], + }); + } + } + + _ => {} + } + } + + Ok(FaultCatalog { locations }) +} + +// ---- Helpers for fault catalog ---- + +fn pauli_type_to_pauli(pt: PauliType) -> Pauli { + match pt { + PauliType::X => Pauli::X, + PauliType::Y => Pauli::Y, + PauliType::Z => Pauli::Z, + } +} + +fn pauli_type_to_string(pt: PauliType, qubit: usize) -> PauliString { + PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + vec![(pauli_type_to_pauli(pt), QubitId(qubit))], + ) +} + +fn pauli_pair_to_string(p1: PauliType, q1: usize, p2: PauliType, q2: usize) -> PauliString { + PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + vec![ + (pauli_type_to_pauli(p1), QubitId(q1)), + (pauli_type_to_pauli(p2), QubitId(q2)), + ], + ) +} + +fn parse_records_from_meta(tc: &TickCircuit, key: &str) -> Vec> { + let Some(pecos_quantum::Attribute::String(json)) = tc.get_meta(key) else { + return Vec::new(); + }; + parse_records_array_list(json) +} + +fn parse_detector_records(tc: &TickCircuit) -> Vec> { + parse_records_from_meta(tc, "detectors") +} + +fn parse_observable_records(tc: &TickCircuit) -> Vec> { + parse_records_from_meta(tc, "observables") +} + +fn parse_tracked_pauli_annotations(tc: &TickCircuit) -> Vec { + tc.annotations() + .iter() + .filter(|ann| matches!(ann.kind, AnnotationKind::TrackedPauli)) + .map(|ann| { + let mut pauli = ann.pauli.clone(); + pauli.set_phase(pecos_core::QuarterPhase::PlusOne); + pauli + }) + .collect() +} + +fn tracked_paulis_flipped_by( + prop: &BitmaskPauliProp, + tracked_paulis: &[PauliString], +) -> Vec { + tracked_paulis + .iter() + .enumerate() + .filter_map(|(idx, tracked_pauli)| { + let mut parity = false; + for &(pauli, qubit) in tracked_pauli.paulis() { + let q = qubit.index(); + match pauli { + Pauli::X => parity ^= prop.contains_z(q), + Pauli::Y => parity ^= prop.contains_x(q) ^ prop.contains_z(q), + Pauli::Z => parity ^= prop.contains_x(q), + Pauli::I => {} + } + } + parity.then_some(idx) + }) + .collect() +} + +/// Simple parser for `[{"records": [...]}, ...]` JSON without `serde_json`. +fn parse_records_array_list(json: &str) -> Vec> { + let json = json.trim(); + if json.is_empty() || json == "[]" { + return Vec::new(); + } + let mut results = Vec::new(); + // Find each "records": [...] within the JSON + let mut search_from = 0; + while let Some(pos) = json[search_from..].find("\"records\"") { + let pos = search_from + pos; + let rest = &json[pos..]; + if let Some(arr_start) = rest.find('[') { + if let Some(arr_end) = rest[arr_start..].find(']') { + let arr_str = &rest[arr_start + 1..arr_start + arr_end]; + let nums: Vec = arr_str + .split(',') + .filter_map(|s| s.trim().parse().ok()) + .collect(); + results.push(nums); + search_from = pos + arr_start + arr_end + 1; + } else { + break; + } + } else { + break; + } + } + results +} + +fn record_absolute_index(num_meas: usize, rec: i32) -> Option { + let base = i64::try_from(num_meas).ok()?; + let abs_idx = base.checked_add(i64::from(rec))?; + usize::try_from(abs_idx).ok() +} + +struct RecordEffectIndex { + detectors_by_measurement: Vec>, + observables_by_measurement: Vec>, +} + +impl RecordEffectIndex { + fn new(det_records: &[Vec], obs_records: &[Vec], num_meas: usize) -> Self { + Self { + detectors_by_measurement: records_by_measurement(det_records, num_meas), + observables_by_measurement: records_by_measurement(obs_records, num_meas), + } + } + + /// Map measurement effects to detector effects via record XOR. + fn detectors_for_measurements(&self, affected_meas: &[usize]) -> Vec { + measurements_to_record_effects(affected_meas, &self.detectors_by_measurement) + } + + /// Map measurement effects to observable effects via record XOR. + fn observables_for_measurements(&self, affected_meas: &[usize]) -> Vec { + measurements_to_record_effects(affected_meas, &self.observables_by_measurement) + } +} + +fn catalog_effect_parts( + effect: PropagatedFaultEffect, + record_effect_index: &RecordEffectIndex, +) -> (Vec, Vec, Vec, Vec) { + let affected: Vec = effect.affected_measurements.into_iter().collect(); + let dets = record_effect_index.detectors_for_measurements(&affected); + let obs = record_effect_index.observables_for_measurements(&affected); + (affected, dets, obs, effect.affected_tracked_paulis) +} + +fn records_by_measurement(records_by_output: &[Vec], num_meas: usize) -> Vec> { + let mut by_measurement = vec![Vec::new(); num_meas]; + for (output_idx, records) in records_by_output.iter().enumerate() { + for &rec in records { + if let Some(meas_idx) = record_absolute_index(num_meas, rec) + && meas_idx < num_meas + { + by_measurement[meas_idx].push(output_idx); + } + } + } + by_measurement +} + +fn measurements_to_record_effects( + affected_meas: &[usize], + outputs_by_measurement: &[Vec], +) -> Vec { + let mut fired = Vec::new(); + for &meas_idx in affected_meas { + if let Some(outputs) = outputs_by_measurement.get(meas_idx) { + for &output_idx in outputs { + toggle_sorted(&mut fired, output_idx); + } + } + } + fired +} + +fn toggle_sorted(values: &mut Vec, value: usize) { + match values.binary_search(&value) { + Ok(pos) => { + values.remove(pos); + } + Err(pos) => { + values.insert(pos, value); + } + } +} + +// ============================================================================ +// Shared symbolic simulation helper +// ============================================================================ + +/// Run `SymbolicSparseStab` through a `TickCircuit` with proper PZ (reset) +/// semantics, returning the `MeasurementHistory` with correct cross-reset +/// correlations. +/// +/// Iterates tick-by-tick to match the `TickCircuit`'s measurement numbering +/// (which detector/DEM-output record indices reference). +/// +/// Errors on unsupported gates with tick/gate/qubit context (same gate set +/// as [`build_fault_table`]). +/// +/// # Errors +/// +/// Returns [`UnsupportedGateError`] when the circuit contains a gate outside +/// the supported Clifford/prep/measurement/metadata set. +pub fn symbolic_measurement_history( + tc: &TickCircuit, +) -> Result { + use pecos_simulators::SymbolicSparseStab; + + let num_qubits = tc + .iter_gate_batches() + .flat_map(|g| g.as_gate().qubits.iter()) + .map(|q| q.index() + 1) + .max() + .unwrap_or(0); + + let mut sim = SymbolicSparseStab::new(num_qubits); + + for (tick_idx, tick) in tc.iter_ticks() { + for gate in tick.iter_gate_batches() { + let gate_idx = gate.batch_index(); + let qs: Vec = gate.qubits.iter().map(pecos_core::QubitId::index).collect(); + + match gate.gate_type { + GateType::PZ | GateType::QAlloc => { + for &q in &qs { + sim.pz(q); + } + } + GateType::H => { + sim.h(&qs); + } + GateType::X => { + sim.x(&qs); + } + GateType::Y => { + sim.y(&qs); + } + GateType::Z => { + sim.z(&qs); + } + GateType::SZ => { + sim.sz(&qs); + } + GateType::SZdg => { + sim.szdg(&qs); + } + GateType::SX => { + sim.sx(&qs); + } + GateType::SXdg => { + sim.sxdg(&qs); + } + GateType::SY => { + sim.sy(&qs); + } + GateType::SYdg => { + sim.sydg(&qs); + } + GateType::F => { + sim.sx(&qs); + sim.sz(&qs); + } + GateType::Fdg => { + sim.szdg(&qs); + sim.sxdg(&qs); + } + GateType::CX => { + let pairs = symbolic_pairs(&qs); + sim.cx(&pairs); + } + GateType::CY => { + sim.cy(&symbolic_pairs(&qs)); + } + GateType::CZ => { + sim.cz(&symbolic_pairs(&qs)); + } + GateType::SXX => { + sim.sxx(&symbolic_pairs(&qs)); + } + GateType::SXXdg => { + sim.sxxdg(&symbolic_pairs(&qs)); + } + GateType::SYY => { + sim.syy(&symbolic_pairs(&qs)); + } + GateType::SYYdg => { + sim.syydg(&symbolic_pairs(&qs)); + } + GateType::SZZ => { + sim.szz(&symbolic_pairs(&qs)); + } + GateType::SZZdg => { + sim.szzdg(&symbolic_pairs(&qs)); + } + GateType::SWAP => { + sim.swap(&symbolic_pairs(&qs)); + } + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked => { + sim.mz(&qs); + } + GateType::I + | GateType::Idle + | GateType::QFree + | GateType::MeasCrosstalkGlobalPayload + | GateType::MeasCrosstalkLocalPayload + | GateType::TrackedPauliMeta => {} + other => { + return Err(UnsupportedGateError { + gate_type: other, + tick: tick_idx, + gate_in_tick: gate_idx, + qubits: qs, + }); + } + } + } + } + + Ok(sim.measurement_history().clone()) +} + +fn symbolic_pairs(qs: &[usize]) -> Vec<(usize, usize)> { + qs.chunks(2) + .filter(|c| c.len() == 2) + .map(|c| (c[0], c[1])) + .collect() +} + +// ============================================================================ +// Raw Measurement Plan: geometric/O(fired) columnar sampling +// ============================================================================ + +/// Zero out bits beyond `shots` in the final word of each column. +fn mask_partial_final_word(columns: &mut [Vec], shots: usize) { + let remainder = shots % 64; + if remainder == 0 { + return; + } + let mask = (1u64 << remainder) - 1; + for col in columns.iter_mut() { + if let Some(last) = col.last_mut() { + *last &= mask; + } + } +} + +/// Columnar raw-measurement result with r-source access. +/// +/// The measurement columns are the final output (base XOR faults). +/// The `r_columns` field holds the latent random source columns that feed +/// into the ideal measurement dependency graph. +pub struct RawSampleResult { + /// Final measurement columns: `columns[meas_idx][word_idx]`, bit i = shot word*64+i. + /// Bits beyond `shots` in the final word are always zero. + pub columns: Vec>, + /// Latent r-source columns (one per Random measurement kind). + /// Bits beyond `shots` in the final word are always zero. + pub r_columns: Vec>, + /// Measurement index that introduced each r-source. + /// `r_source_measurements[k]` is the measurement index for `r_columns[k]`. + pub r_source_measurements: Vec, + pub shots: usize, +} + +/// A compiled plan for sampling raw measurements from a stochastic circuit. +/// +/// Combines: +/// - **r-sources** (p=0.5): non-deterministic measurement variables from the +/// ideal dependency graph. These fan out through Copy/Computed relationships. +/// - **Physical mechanisms**: depolarizing gate faults, prep faults, +/// measurement flips. These do NOT fan out through ideal dependencies. +/// +/// Physical mechanisms are sampled using geometric skip (O(fired events) per +/// mechanism), matching the DEM sampler's performance characteristics. +pub struct RawMeasurementPlan { + pub num_measurements: usize, + kinds: Vec, + pub mechanisms: Vec, + /// Precomputed 1/ln(1-p) for geometric skip sampling, one per mechanism. + inv_log_1_minus_p: Vec, +} + +impl RawMeasurementPlan { + /// Build a plan from a measurement history and fault mechanisms. + #[must_use] + pub fn new(history: &MeasurementHistory, mechanisms: Vec) -> Self { + let kinds = MeasurementKind::from_history(history); + let inv_log_1_minus_p = mechanisms + .iter() + .map(|m| { + let log_1_minus_p = (1.0 - m.probability).ln(); + if log_1_minus_p.abs() < f64::EPSILON { + 0.0 + } else { + 1.0 / log_1_minus_p + } + }) + .collect(); + Self { + num_measurements: kinds.len(), + kinds, + mechanisms, + inv_log_1_minus_p, + } + } + + /// Sample raw measurements using geometric skip for physical faults. + /// + /// Returns a `SampleResult` for compatibility with existing code. + /// For r-event access, use [`sample_raw`]. + #[must_use] + pub fn sample(&self, shots: usize, seed: u64) -> SampleResult { + let raw = self.sample_raw(shots, seed); + SampleResult::new(raw.columns, shots) + } + + /// Sample raw measurements with r-source column access. + /// + /// Physical mechanisms use geometric skip: O(p * shots) RNG calls per + /// mechanism, not O(shots). For typical QEC noise (p ~ 0.005, 20k shots), + /// this is ~100 firings per mechanism vs 20000 iterations. + #[must_use] + pub fn sample_raw(&self, shots: usize, seed: u64) -> RawSampleResult { + if shots == 0 { + let r_source_measurements = self.r_source_indices(); + return RawSampleResult { + columns: vec![Vec::new(); self.num_measurements], + r_columns: vec![Vec::new(); r_source_measurements.len()], + r_source_measurements, + shots: 0, + }; + } + + let num_words = shots.div_ceil(64); + + // 1. Sample base values (r-sources + constants) and capture r columns + let mut rng_base = PecosRng::seed_from_u64(seed); + let (mut columns, mut r_columns) = self.sample_base(num_words, &mut rng_base); + + // 2. Overlay physical faults using geometric skip + if !self.mechanisms.is_empty() { + let mut rng_fault = PecosRng::seed_from_u64(seed.wrapping_add(1)); + self.overlay_faults_geometric(shots, &mut columns, &mut rng_fault); + } + + // 3. Mask partial final word so bits beyond `shots` are always zero + mask_partial_final_word(&mut columns, shots); + mask_partial_final_word(&mut r_columns, shots); + + RawSampleResult { + columns, + r_columns, + r_source_measurements: self.r_source_indices(), + shots, + } + } + + /// Returns the measurement indices that correspond to r-sources (Random kinds). + fn r_source_indices(&self) -> Vec { + self.kinds + .iter() + .enumerate() + .filter_map(|(i, k)| { + if matches!(k, MeasurementKind::Random) { + Some(i) + } else { + None + } + }) + .collect() + } + + /// Sample base measurement values from r-sources and constants. + /// Returns (`measurement_columns`, `r_source_columns`). + fn sample_base(&self, num_words: usize, rng: &mut PecosRng) -> (Vec>, Vec>) { + let mut columns: Vec> = Vec::with_capacity(self.num_measurements); + let mut r_columns: Vec> = Vec::new(); + + for kind in &self.kinds { + match kind { + MeasurementKind::Fixed(value) => { + let fill = if *value { !0u64 } else { 0u64 }; + columns.push(vec![fill; num_words]); + } + MeasurementKind::Random => { + let mut col = vec![0u64; num_words]; + for word in &mut col { + *word = rng.next_u64(); + } + r_columns.push(col.clone()); + columns.push(col); + } + MeasurementKind::Copy(src) => { + columns.push(columns[*src].clone()); + } + MeasurementKind::CopyFlipped(src) => { + let flipped: Vec = columns[*src].iter().map(|w| !w).collect(); + columns.push(flipped); + } + MeasurementKind::Computed { deps, flip } => { + let init = if *flip { !0u64 } else { 0u64 }; + let mut col = vec![init; num_words]; + for &dep in deps { + for (w, &d) in col.iter_mut().zip(columns[dep].iter()) { + *w ^= d; + } + } + columns.push(col); + } + } + } + + (columns, r_columns) + } + + /// Overlay physical faults using geometric skip sampling. + /// + /// For each mechanism with probability p: + /// - Precomputed `inv_log = 1/ln(1-p)` + /// - Sample `skip = floor(ln(U) * inv_log)` to jump to next fired shot + /// - At fired shot: choose uniform alternative, XOR affected measurements + /// + /// Complexity: O(p * shots) per mechanism (geometric = O(fired events)). + fn overlay_faults_geometric(&self, shots: usize, columns: &mut [Vec], rng: &mut PecosRng) { + let num_words = columns.first().map_or(0, Vec::len); + for (mech_idx, mechanism) in self.mechanisms.iter().enumerate() { + let inv_log = self.inv_log_1_minus_p[mech_idx]; + let p = mechanism.probability; + let num_alts = mechanism.alternatives.len(); + if num_alts == 0 { + continue; + } + + // p=1: every shot fires (handle before inv_log check since inv_log=0 for p=1) + if p >= 1.0 { + if num_alts == 1 { + let word_masks = full_shot_word_masks(shots, num_words); + apply_word_masks(columns, &mechanism.alternatives[0], &word_masks); + } else { + let mut alt_word_masks = vec![vec![0u64; num_words]; num_alts]; + for shot in 0..shots { + let word_idx = shot / 64; + let bit_idx = shot % 64; + let alt_idx = rng.random_range(0..num_alts); + alt_word_masks[alt_idx][word_idx] ^= 1u64 << bit_idx; + } + apply_alternative_word_masks(columns, mechanism, &alt_word_masks); + } + continue; + } + + // Skip p=0 mechanisms (inv_log=0 means p≈0 or exactly 0) + if p == 0.0 || inv_log == 0.0 { + continue; + } + + // Geometric skip sampling: O(fired events) + let mut shot: usize = 0; + let mut alt_word_masks: Option>> = None; + while shot < shots { + // Sample skip distance + #[allow(clippy::cast_precision_loss)] + let u = (rng.next_u64() as f64) / (u64::MAX as f64); + let u = if u == 0.0 { f64::MIN_POSITIVE } else { u }; + #[allow(clippy::cast_possible_truncation, clippy::cast_sign_loss)] + let skip = (u.ln() * inv_log).floor() as usize; + + shot += skip; + if shot >= shots { + break; + } + + // This shot fires — choose alternative and XOR + let word_idx = shot / 64; + let bit_idx = shot % 64; + let mask = 1u64 << bit_idx; + + let alt_idx = if num_alts == 1 { + 0 + } else { + rng.random_range(0..num_alts) + }; + alt_word_masks.get_or_insert_with(|| vec![vec![0u64; num_words]; num_alts]) + [alt_idx][word_idx] ^= mask; + + shot += 1; + } + if let Some(alt_word_masks) = alt_word_masks { + apply_alternative_word_masks(columns, mechanism, &alt_word_masks); + } + } + } +} + +fn full_shot_word_masks(shots: usize, num_words: usize) -> Vec { + let mut masks = vec![!0u64; num_words]; + mask_partial_final_word(std::slice::from_mut(&mut masks), shots); + masks +} + +fn apply_alternative_word_masks( + columns: &mut [Vec], + mechanism: &FaultMechanism, + alt_word_masks: &[Vec], +) { + for (measurements, word_masks) in mechanism.alternatives.iter().zip(alt_word_masks) { + apply_word_masks(columns, measurements, word_masks); + } +} + +fn apply_word_masks(columns: &mut [Vec], measurements: &[usize], word_masks: &[u64]) { + if word_masks.iter().all(|&mask| mask == 0) { + return; + } + for &meas_idx in measurements { + if let Some(column) = columns.get_mut(meas_idx) { + for (word, &mask) in column.iter_mut().zip(word_masks) { + *word ^= mask; + } + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn assert_close(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < 1e-12, + "expected {expected}, got {actual}" + ); + } + + #[test] + fn test_record_effect_index_maps_measurement_effects_by_xor() { + let det_records = vec![vec![-1], vec![-2, -1], vec![-1, -1], vec![-4], vec![1]]; + let obs_records = vec![vec![-2], vec![-1, -2]]; + let index = RecordEffectIndex::new(&det_records, &obs_records, 3); + + assert_eq!(index.detectors_for_measurements(&[2]), vec![0, 1]); + assert_eq!(index.detectors_for_measurements(&[1, 2]), vec![0]); + assert_eq!(index.observables_for_measurements(&[1]), vec![0, 1]); + assert_eq!(index.observables_for_measurements(&[1, 2]), vec![0]); + } + + /// Build a minimal `TickCircuit`: PZ(0) H(0) CX(0,1) H(0) MZ(0) PZ(0) H(0) CX(0,1) H(0) MZ(0) + fn two_round_x_check() -> TickCircuit { + let mut tc = TickCircuit::new(); + // Round 1 + tc.tick().h(&[QubitId(0)]); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.tick().pz(&[QubitId(0)]); + // Round 2 + tc.tick().h(&[QubitId(0)]); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc + } + + #[test] + fn test_meas_fault_affects_single_measurement() { + let tc = two_round_x_check(); + let noise = StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }; + let mechanisms = build_fault_table(&tc, &noise).unwrap(); + + // Should have exactly 2 measurement mechanisms (one per MZ), + // each with 1 alternative that flips that measurement. + assert_eq!(mechanisms.len(), 2); + assert_eq!(mechanisms[0].alternatives, vec![vec![0]]); + assert_eq!(mechanisms[1].alternatives, vec![vec![1]]); + assert!((mechanisms[0].probability - 0.01).abs() < 1e-10); + } + + #[test] + fn test_prep_fault_reaches_next_measurement_only() { + let tc = two_round_x_check(); + let noise = StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.01, + }; + let mechanisms = build_fault_table(&tc, &noise).unwrap(); + + // PZ(0) before round 2: single alternative affecting only m1 + let round2_prep = mechanisms.iter().find(|m| m.alternatives == vec![vec![1]]); + assert!( + round2_prep.is_some(), + "PZ before round 2 should produce mechanism affecting m1" + ); + } + + #[test] + fn test_prep_fault_does_not_cross_pz() { + let tc = two_round_x_check(); + let noise = StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.01, + }; + let mechanisms = build_fault_table(&tc, &noise).unwrap(); + + // No alternative should affect BOTH m0 and m1 (PZ between rounds absorbs) + for m in &mechanisms { + for alt in &m.alternatives { + assert!( + !(alt.contains(&0) && alt.contains(&1)), + "Fault alternative crosses PZ boundary: {alt:?}" + ); + } + } + } + + #[test] + fn test_flatten_tick_circuit_preserves_source_metadata() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0), QubitId(1)]); + tc.tick() + .cx(&[(QubitId(0), QubitId(1)), (QubitId(2), QubitId(3))]); + tc.tick().mz(&[QubitId(0), QubitId(1)]); + + let (gates, meas_positions) = flatten_tick_circuit(&tc); + + assert_eq!(gates.len(), 6); + assert_eq!(meas_positions.get(&4), Some(&0)); + assert_eq!(meas_positions.get(&5), Some(&1)); + + assert_eq!(gates[0].tick, 0); + assert_eq!(gates[0].gate_index, 0); + assert_eq!(gates[0].gate_type, GateType::H); + assert_eq!(gates[0].qubits, vec![0]); + + assert_eq!(gates[1].tick, 0); + assert_eq!(gates[1].gate_index, 0); + assert_eq!(gates[1].gate_type, GateType::H); + assert_eq!(gates[1].qubits, vec![1]); + + assert_eq!(gates[2].tick, 1); + assert_eq!(gates[2].gate_index, 0); + assert_eq!(gates[2].gate_type, GateType::CX); + assert_eq!(gates[2].qubits, vec![0, 1]); + + assert_eq!(gates[3].tick, 1); + assert_eq!(gates[3].gate_index, 0); + assert_eq!(gates[3].gate_type, GateType::CX); + assert_eq!(gates[3].qubits, vec![2, 3]); + + assert_eq!(gates[4].tick, 2); + assert_eq!(gates[4].gate_index, 0); + assert_eq!(gates[4].gate_type, GateType::MZ); + assert_eq!(gates[4].qubits, vec![0]); + + assert_eq!(gates[5].tick, 2); + assert_eq!(gates[5].gate_index, 0); + assert_eq!(gates[5].gate_type, GateType::MZ); + assert_eq!(gates[5].qubits, vec![1]); + } + + #[test] + fn test_flatten_tick_circuit_skips_zero_gate_metadata_batches() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.insert_tick(1); + tc.get_tick_mut(1) + .unwrap() + .add_gate(pecos_core::Gate::simple( + GateType::TrackedPauliMeta, + vec![QubitId(1), QubitId(2)], + )); + tc.tick().mz(&[QubitId(0)]); + + let (gates, meas_positions) = flatten_tick_circuit(&tc); + + assert_eq!(gates.len(), 2); + assert_eq!(gates[0].gate_type, GateType::H); + assert_eq!(gates[1].gate_type, GateType::MZ); + assert_eq!(meas_positions.get(&1), Some(&0)); + } + + // ---- Direct propagation tests using propagate_single ---- + + #[test] + fn test_propagate_x_before_cx_reaches_target_mz() { + // Circuit: CX(0,1) MZ(1) + // X on q0 before CX: CX maps XI → XX → MZ(q1) sees X → flips + let mut tc = TickCircuit::new(); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(1)]); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + let affected = propagate_single(PauliType::X, 0, 0, &gates, &meas_pos); + assert_eq!( + affected, + BTreeSet::from([0]), + "X on q0 before CX(0,1) MZ(1) should flip m0" + ); + } + + #[test] + fn test_propagate_z_before_cx_stays_on_control() { + // Circuit: CX(0,1) MZ(1) + // Z on q0 before CX: CX maps ZI → ZI → MZ(q1) sees I → no flip + let mut tc = TickCircuit::new(); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(1)]); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + let affected = propagate_single(PauliType::Z, 0, 0, &gates, &meas_pos); + assert!( + affected.is_empty(), + "Z on q0 before CX(0,1) should not reach MZ(q1)" + ); + } + + #[test] + fn test_propagate_x_on_target_unchanged_by_cx() { + // Circuit: CX(0,1) MZ(1) + // X on q1 before CX: CX maps IX → IX → MZ(q1) sees X → flips + let mut tc = TickCircuit::new(); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(1)]); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + let affected = propagate_single(PauliType::X, 1, 0, &gates, &meas_pos); + assert_eq!(affected, BTreeSet::from([0])); + } + + #[test] + fn test_propagate_z_on_target_spreads_to_control_via_cx() { + // Circuit: CX(0,1) MZ(0) MZ(1) + // Z on q1 before CX: CX maps IZ → ZZ → MZ(q0) sees Z (no flip), MZ(q1) sees Z (no flip) + let mut tc = TickCircuit::new(); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(1)]); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + let affected = propagate_single(PauliType::Z, 1, 0, &gates, &meas_pos); + assert!( + affected.is_empty(), + "Z errors don't flip Z-basis measurements" + ); + } + + #[test] + fn test_propagate_x_through_h_becomes_z() { + // Circuit: H(0) MZ(0) + // X on q0 at position 0: H maps X→Z → MZ sees Z → no flip + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + let affected = propagate_single(PauliType::X, 0, 0, &gates, &meas_pos); + assert!( + affected.is_empty(), + "X through H becomes Z, should not flip MZ" + ); + } + + #[test] + fn test_propagate_z_through_h_becomes_x() { + // Circuit: H(0) MZ(0) + // Z on q0 at position 0: H maps Z→X → MZ sees X → flips + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + let affected = propagate_single(PauliType::Z, 0, 0, &gates, &meas_pos); + assert_eq!( + affected, + BTreeSet::from([0]), + "Z through H becomes X, should flip MZ" + ); + } + + #[test] + fn test_propagate_x_absorbed_by_pz() { + // Circuit: PZ(0) MZ(0) + // X on q0 at position 0: PZ absorbs it → MZ sees I → no flip + let mut tc = TickCircuit::new(); + tc.tick().pz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + let affected = propagate_single(PauliType::X, 0, 0, &gates, &meas_pos); + assert!(affected.is_empty(), "X should be absorbed by PZ"); + } + + #[test] + fn test_pz_absorbs_all_pauli_components_before_reset() { + // Circuit: PZ(0) H(0) MZ(0) + // Any fault before the reset is absorbed. Faults after the reset still + // propagate through the H according to normal Clifford conjugation. + let mut tc = TickCircuit::new(); + tc.tick().pz(&[QubitId(0)]); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + for pauli in [PauliType::X, PauliType::Y, PauliType::Z] { + let affected = propagate_single(pauli, 0, 0, &gates, &meas_pos); + assert!( + affected.is_empty(), + "{pauli:?} before PZ should be absorbed by the reset" + ); + } + + assert!( + propagate_single(PauliType::X, 0, 1, &gates, &meas_pos).is_empty(), + "X after PZ becomes Z through H and should not flip MZ" + ); + assert_eq!( + propagate_single(PauliType::Y, 0, 1, &gates, &meas_pos), + BTreeSet::from([0]), + "Y after PZ keeps an X component through H and should flip MZ" + ); + assert_eq!( + propagate_single(PauliType::Z, 0, 1, &gates, &meas_pos), + BTreeSet::from([0]), + "Z after PZ becomes X through H and should flip MZ" + ); + } + + #[test] + fn test_propagate_x_absorbed_by_mz() { + // Circuit: MZ(0) MZ(0) — X on q0 should flip first MZ only + // (MZ collapses qubit, absorbing the error) + let mut tc = TickCircuit::new(); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + let affected = propagate_single(PauliType::X, 0, 0, &gates, &meas_pos); + assert_eq!( + affected, + BTreeSet::from([0]), + "X should flip first MZ only, not second" + ); + } + + #[test] + fn test_xor_combined_single_effects_match_pair_propagation() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().h(&[QubitId(1)]); + tc.tick().mz(&[QubitId(0), QubitId(1)]); + tc.tracked_pauli_labeled("tracked_z0", PauliString::z(0)); + tc.tracked_pauli_labeled("tracked_z1", PauliString::z(1)); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + let tracked_paulis = parse_tracked_pauli_annotations(&tc); + let start = 1; + let left = + propagate_single_effect(PauliType::X, 0, start, &gates, &meas_pos, &tracked_paulis); + let right = + propagate_single_effect(PauliType::Z, 1, start, &gates, &meas_pos, &tracked_paulis); + let combined = xor_fault_effects(&left, &right); + let direct = propagate_pair_effect( + [(PauliType::X, 0), (PauliType::Z, 1)], + start, + &gates, + &meas_pos, + &tracked_paulis, + ); + + assert_eq!(combined, direct); + } + + #[test] + fn test_propagated_effect_cache_matches_fresh_propagation() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.tracked_pauli_labeled("tracked_x0", PauliString::x(0)); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + let tracked_paulis = parse_tracked_pauli_annotations(&tc); + let fresh = propagate_single_effect(PauliType::Z, 0, 0, &gates, &meas_pos, &tracked_paulis); + + let mut cache = PropagatedEffectCache::default(); + let first = cache.single(PauliType::Z, 0, 0, &gates, &meas_pos, &tracked_paulis); + assert_eq!(first, fresh); + assert_eq!(cache.len(), 1); + + let mut mutated_clone = first.clone(); + mutated_clone.affected_measurements.clear(); + mutated_clone.affected_tracked_paulis.clear(); + + let second = cache.single(PauliType::Z, 0, 0, &gates, &meas_pos, &tracked_paulis); + assert_eq!(second, fresh); + assert_ne!(second, mutated_clone); + assert_eq!( + cache.len(), + 1, + "repeating the same propagation key should reuse the cached entry" + ); + + let other = cache.single(PauliType::X, 0, 0, &gates, &meas_pos, &tracked_paulis); + let other_fresh = + propagate_single_effect(PauliType::X, 0, 0, &gates, &meas_pos, &tracked_paulis); + assert_eq!(other, other_fresh); + assert_eq!(cache.len(), 2); + } + + #[test] + fn test_propagate_x_check_round_reaches_ancilla_only() { + // X-check pattern: H(0) CX(0,1) CX(0,2) H(0) MZ(0) + // X on q1 (data) at start: CX maps IX→IX on q1 (target stays). + // After H-CX-CX-H, X on q1 doesn't propagate to ancilla. + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().cx(&[(QubitId(0), QubitId(2))]); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + + let (gates, meas_pos) = flatten_tick_circuit(&tc); + + // X on data q1: CX(ctrl=0, tgt=1) doesn't spread X from target to control. + // So X stays on q1, never reaches MZ(q0). + let affected = propagate_single(PauliType::X, 1, 0, &gates, &meas_pos); + assert!( + affected.is_empty(), + "X on data qubit should not reach ancilla MZ in X-check" + ); + + // Z on data q1: CX maps IZ → ZZ (spreads to control q0). + // Then H(q0) maps Z→X on ancilla. MZ(q0) sees X → flips. + let affected = propagate_single(PauliType::Z, 1, 0, &gates, &meas_pos); + assert_eq!( + affected, + BTreeSet::from([0]), + "Z on data should reach ancilla MZ in X-check" + ); + } + + #[test] + fn test_empty_alternative_preserved_for_correct_denominator() { + // H(0); MZ(0): p1 faults are injected AFTER H, directly before MZ. + // The 3 alternatives (X, Y, Z injected between H and MZ): + // X: has X component → flips MZ + // Y: has X component → flips MZ + // Z: commutes with MZ → no flip (empty alternative) + // All 3 must be present so each is chosen with probability 1/3. + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + + let noise = StochasticNoiseParams { + p1: 0.01, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }; + let mechanisms = build_fault_table(&tc, &noise).unwrap(); + + assert_eq!(mechanisms.len(), 1, "one mechanism for the H gate"); + let m = &mechanisms[0]; + assert_eq!( + m.alternatives.len(), + 3, + "all 3 Pauli alternatives must be present" + ); + // Exactly one alternative should be empty (Z between H and MZ commutes) + let empty_count = m.alternatives.iter().filter(|a| a.is_empty()).count(); + assert_eq!( + empty_count, 1, + "Z injected after H commutes with MZ — should be empty no-op alternative" + ); + } + + #[test] + fn test_zero_noise_produces_no_faults() { + let tc = two_round_x_check(); + let noise = StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }; + let faults = build_fault_table(&tc, &noise).unwrap(); + assert!(faults.is_empty()); + } + + #[test] + fn test_unsupported_gate_rejected_even_with_zero_noise() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().t(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + + // Zero noise — validation runs on raw TickCircuit before anything else + let noise = StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }; + let result = build_fault_table(&tc, &noise); + assert!(result.is_err(), "T should be rejected"); + let err = result.unwrap_err(); + assert_eq!(err.gate_type, GateType::T); + assert_eq!(err.tick, 1, "T is in tick 1"); + assert_eq!(err.gate_in_tick, 0, "T is gate 0 within that tick"); + assert_eq!(err.qubits, vec![0], "full original qubit list"); + } + + // ---- symbolic_measurement_history tests ---- + + #[test] + fn test_symbolic_history_rejects_unsupported_gate() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().t(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + + let result = symbolic_measurement_history(&tc); + assert!(result.is_err(), "T should be rejected"); + let err = result.unwrap_err(); + assert_eq!(err.gate_type, GateType::T); + assert_eq!(err.tick, 1); + assert_eq!(err.qubits, vec![0]); + } + + #[test] + fn test_symbolic_history_cy_circuit_succeeds() { + // CY(0,1) MZ(1): should not error; CY is a valid Clifford gate + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + let pairs = [(QubitId(0), QubitId(1))]; + tc.tick().cy(&pairs); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(1)]); + + let history = symbolic_measurement_history(&tc); + assert!(history.is_ok(), "CY should be supported"); + assert_eq!(history.unwrap().len(), 2); + } + + #[test] + fn test_symbolic_history_bell_produces_correct_kinds() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(1)]); + + let history = symbolic_measurement_history(&tc).unwrap(); + let kinds = MeasurementKind::from_history(&history); + assert_eq!(kinds.len(), 2); + assert!(matches!(kinds[0], MeasurementKind::Random)); + assert!(matches!(kinds[1], MeasurementKind::Copy(0))); + } + + #[test] + fn test_symbolic_history_reset_breaks_copy_chain_between_rounds() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(1)]); + tc.tick().pz(&[QubitId(0)]); + tc.tick().pz(&[QubitId(1)]); + tc.tick().h(&[QubitId(0)]); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(1)]); + + let history = symbolic_measurement_history(&tc).unwrap(); + let kinds = MeasurementKind::from_history(&history); + assert_eq!(kinds.len(), 4); + assert!(matches!(kinds[0], MeasurementKind::Random)); + assert!(matches!(kinds[1], MeasurementKind::Copy(0))); + assert!( + matches!(kinds[2], MeasurementKind::Random), + "measurement after reset should introduce a fresh random source" + ); + assert!( + !matches!(kinds[2], MeasurementKind::Copy(0)), + "reset must break the copy chain from the first round" + ); + assert!(matches!(kinds[3], MeasurementKind::Copy(2))); + } + + // ---- FaultCatalog tests ---- + + #[test] + fn test_catalog_single_qubit_depolarizing() { + // H(0) MZ(0): p1 fault after H has 3 alternatives + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + + // Should have exactly 1 location (H gate) with 3 alternatives + let h_locs: Vec<_> = catalog + .locations + .iter() + .filter(|l| l.gate_type == GateType::H) + .collect(); + assert_eq!(h_locs.len(), 1); + let loc = &h_locs[0]; + assert_eq!(loc.faults.len(), 3); + assert_eq!(loc.channel, FaultChannel::P1); + assert!((loc.channel_probability - 0.03).abs() < 1e-10); + assert!((loc.no_fault_probability - 0.97).abs() < 1e-10); + assert_eq!(loc.num_alternatives, 3); + + for fault in &loc.faults { + assert_eq!(fault.kind, FaultKind::Pauli); + assert!(fault.pauli.is_some()); + assert!((fault.conditional_probability - 1.0 / 3.0).abs() < 1e-10); + assert!((fault.absolute_probability - 0.01).abs() < 1e-10); + } + } + + #[test] + fn test_catalog_two_qubit_depolarizing() { + // CX(0,1) MZ(0) MZ(1): p2 fault has 15 alternatives + let mut tc = TickCircuit::new(); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(1)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("2".to_string()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let noise = StochasticNoiseParams { + p1: 0.0, + p2: 0.15, + p_meas: 0.0, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + + let cx_locs: Vec<_> = catalog + .locations + .iter() + .filter(|l| l.gate_type == GateType::CX) + .collect(); + assert_eq!(cx_locs.len(), 1); + let loc = &cx_locs[0]; + assert_eq!(loc.faults.len(), 15); + assert_eq!(loc.num_alternatives, 15); + + for fault in &loc.faults { + assert_eq!(fault.kind, FaultKind::Pauli); + assert!(fault.pauli.is_some()); + assert!((fault.conditional_probability - 1.0 / 15.0).abs() < 1e-10); + assert!((fault.absolute_probability - 0.01).abs() < 1e-10); + } + + // Verify 9 two-qubit PauliStrings and 6 single-qubit PauliStrings + let two_term: usize = loc + .faults + .iter() + .filter(|f| f.pauli.as_ref().unwrap().iter_pairs().count() == 2) + .count(); + let one_term: usize = loc + .faults + .iter() + .filter(|f| f.pauli.as_ref().unwrap().iter_pairs().count() == 1) + .count(); + assert_eq!(two_term, 9, "Should have 9 two-qubit Pauli alternatives"); + assert_eq!(one_term, 6, "Should have 6 single-qubit Pauli alternatives"); + } + + #[test] + fn test_catalog_supports_all_traced_qis_clifford_gates() { + let mut tc = TickCircuit::new(); + tc.tick().szdg(&[QubitId(0)]); + tc.tick().sx(&[QubitId(0)]); + tc.tick().sxdg(&[QubitId(1)]); + tc.tick().sy(&[QubitId(0)]); + tc.tick().sydg(&[QubitId(1)]); + tc.tick().f(&[QubitId(0)]); + tc.tick().fdg(&[QubitId(1)]); + tc.tick().cy(&[(QubitId(0), QubitId(1))]); + tc.tick().cz(&[(QubitId(0), QubitId(1))]); + tc.tick().sxx(&[(QubitId(0), QubitId(1))]); + tc.tick().sxxdg(&[(QubitId(0), QubitId(1))]); + tc.tick().syy(&[(QubitId(0), QubitId(1))]); + tc.tick().syydg(&[(QubitId(0), QubitId(1))]); + tc.tick().szz(&[(QubitId(0), QubitId(1))]); + tc.tick().szzdg(&[(QubitId(0), QubitId(1))]); + tc.tick().swap(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0), QubitId(1)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("2".to_string()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.15, + p_meas: 0.0, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + + for (gate_type, expected_alternatives) in [ + (GateType::SZdg, 3), + (GateType::SX, 3), + (GateType::SXdg, 3), + (GateType::SY, 3), + (GateType::SYdg, 3), + (GateType::F, 3), + (GateType::Fdg, 3), + (GateType::CY, 15), + (GateType::CZ, 15), + (GateType::SXX, 15), + (GateType::SXXdg, 15), + (GateType::SYY, 15), + (GateType::SYYdg, 15), + (GateType::SZZ, 15), + (GateType::SZZdg, 15), + (GateType::SWAP, 15), + ] { + let locations: Vec<_> = catalog + .locations + .iter() + .filter(|loc| loc.gate_type == gate_type) + .collect(); + assert_eq!(locations.len(), 1, "{gate_type:?}"); + assert_eq!( + locations[0].faults.len(), + expected_alternatives, + "{gate_type:?}" + ); + } + } + + #[test] + fn test_catalog_fault_effects_through_new_clifford_gates() { + fn fault_for_pauli<'a>( + loc: &'a FaultLocation, + pauli: &PauliString, + ) -> &'a FaultAlternative { + loc.faults + .iter() + .find(|fault| fault.pauli.as_ref() == Some(pauli)) + .expect("missing expected Pauli fault") + } + + let mut single = TickCircuit::new(); + single.tick().h(&[QubitId(0)]); + single.tick().sy(&[QubitId(0)]); + single.tick().mz(&[QubitId(0)]); + single.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + single.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + single.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let single_catalog = build_fault_catalog( + &single, + &StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + let h_loc = single_catalog + .locations + .iter() + .find(|loc| loc.gate_type == GateType::H) + .unwrap(); + assert_eq!( + fault_for_pauli(h_loc, &pauli_type_to_string(PauliType::X, 0)).affected_measurements, + Vec::::new(), + "SY maps X to Z, so it should not flip MZ" + ); + assert_eq!( + fault_for_pauli(h_loc, &pauli_type_to_string(PauliType::Y, 0)).affected_measurements, + vec![0], + "SY maps Y to Y, so it should flip MZ" + ); + assert_eq!( + fault_for_pauli(h_loc, &pauli_type_to_string(PauliType::Z, 0)).affected_measurements, + vec![0], + "SY maps Z to X, so it should flip MZ" + ); + + let mut face = TickCircuit::new(); + face.tick().h(&[QubitId(0)]); + face.tick().f(&[QubitId(0)]); + face.tick().mz(&[QubitId(0)]); + face.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + face.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + face.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let face_catalog = build_fault_catalog( + &face, + &StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + let h_loc = face_catalog + .locations + .iter() + .find(|loc| loc.gate_type == GateType::H) + .unwrap(); + assert_eq!( + fault_for_pauli(h_loc, &pauli_type_to_string(PauliType::X, 0)).affected_measurements, + vec![0], + "F maps X to Y, so it should flip MZ" + ); + assert_eq!( + fault_for_pauli(h_loc, &pauli_type_to_string(PauliType::Y, 0)).affected_measurements, + Vec::::new(), + "F maps Y to Z, so it should not flip MZ" + ); + assert_eq!( + fault_for_pauli(h_loc, &pauli_type_to_string(PauliType::Z, 0)).affected_measurements, + vec![0], + "F maps Z to X, so it should flip MZ" + ); + + let mut face_dagger = TickCircuit::new(); + face_dagger.tick().h(&[QubitId(0)]); + face_dagger.tick().fdg(&[QubitId(0)]); + face_dagger.tick().mz(&[QubitId(0)]); + face_dagger.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + face_dagger.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + face_dagger.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let face_dagger_catalog = build_fault_catalog( + &face_dagger, + &StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + let h_loc = face_dagger_catalog + .locations + .iter() + .find(|loc| loc.gate_type == GateType::H) + .unwrap(); + assert_eq!( + fault_for_pauli(h_loc, &pauli_type_to_string(PauliType::X, 0)).affected_measurements, + Vec::::new(), + "Fdg maps X to Z, so it should not flip MZ" + ); + assert_eq!( + fault_for_pauli(h_loc, &pauli_type_to_string(PauliType::Y, 0)).affected_measurements, + vec![0], + "Fdg maps Y to X, so it should flip MZ" + ); + assert_eq!( + fault_for_pauli(h_loc, &pauli_type_to_string(PauliType::Z, 0)).affected_measurements, + vec![0], + "Fdg maps Z to Y, so it should flip MZ" + ); + + let mut two_qubit = TickCircuit::new(); + two_qubit.tick().cx(&[(QubitId(0), QubitId(1))]); + two_qubit.tick().sxx(&[(QubitId(0), QubitId(1))]); + two_qubit.tick().mz(&[QubitId(0), QubitId(1)]); + two_qubit.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("2".to_string()), + ); + two_qubit.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + two_qubit.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let two_catalog = build_fault_catalog( + &two_qubit, + &StochasticNoiseParams { + p1: 0.0, + p2: 0.15, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + let cx_loc = two_catalog + .locations + .iter() + .find(|loc| loc.gate_type == GateType::CX) + .unwrap(); + assert_eq!( + fault_for_pauli(cx_loc, &pauli_type_to_string(PauliType::X, 0)).affected_measurements, + vec![0], + "SXX leaves XI as XI" + ); + assert_eq!( + fault_for_pauli(cx_loc, &pauli_type_to_string(PauliType::X, 1)).affected_measurements, + vec![1], + "SXX leaves IX as IX" + ); + assert_eq!( + fault_for_pauli(cx_loc, &pauli_type_to_string(PauliType::Z, 0)).affected_measurements, + vec![0, 1], + "SXX maps ZI to YX" + ); + assert_eq!( + fault_for_pauli(cx_loc, &pauli_type_to_string(PauliType::Z, 1)).affected_measurements, + vec![0, 1], + "SXX maps IZ to XY" + ); + } + + #[test] + fn test_catalog_keeps_observables_and_tracked_paulis_distinct() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tracked_pauli_labeled("tracked_z0", PauliString::z(0)); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let catalog = build_fault_catalog( + &tc, + &StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + + let h_loc = catalog + .locations + .iter() + .find(|loc| loc.gate_type == GateType::H) + .unwrap(); + let x_fault = h_loc + .faults + .iter() + .find(|fault| fault.pauli.as_ref() == Some(&PauliString::x(0))) + .unwrap(); + let y_fault = h_loc + .faults + .iter() + .find(|fault| fault.pauli.as_ref() == Some(&PauliString::y(0))) + .unwrap(); + let z_fault = h_loc + .faults + .iter() + .find(|fault| fault.pauli.as_ref() == Some(&PauliString::z(0))) + .unwrap(); + + assert_eq!(x_fault.affected_observables, Vec::::new()); + assert_eq!(x_fault.affected_tracked_paulis, vec![0]); + assert_eq!(y_fault.affected_tracked_paulis, vec![0]); + assert_eq!(z_fault.affected_tracked_paulis, Vec::::new()); + + let configs: Vec<_> = catalog.fault_configurations(1).collect(); + assert!( + configs + .iter() + .any(|config| config.affected_tracked_paulis.as_slice() == [0] + && config.affected_observables.is_empty()) + ); + } + + #[test] + fn test_catalog_after_tick_dag_round_trip_keeps_outputs_and_tracked_paulis_separate() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0), QubitId(1)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String(tc.num_measurements().to_string()), + ); + tc.add_detector_metadata(&[-1], None, Some("D0"), Some(0)) + .unwrap(); + tc.add_observable_metadata(&[-1], Some(0), Some("L0")) + .unwrap(); + tc.tracked_pauli_labeled("tracked_z1", PauliString::z(1)); + + let round_tripped = TickCircuit::from(&pecos_quantum::DagCircuit::from(&tc)); + assert_eq!(round_tripped.annotations().len(), 1); + assert!(matches!( + round_tripped.annotations()[0].kind, + AnnotationKind::TrackedPauli + )); + + let catalog = build_fault_catalog( + &round_tripped, + &StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }, + ) + .unwrap(); + + let h_loc = catalog + .locations + .iter() + .find(|loc| loc.gate_type == GateType::H && loc.qubits.as_slice() == [0]) + .unwrap(); + let x_fault = h_loc + .faults + .iter() + .find(|fault| fault.pauli.as_ref() == Some(&PauliString::x(0))) + .unwrap(); + assert_eq!(x_fault.affected_measurements, vec![0]); + assert_eq!(x_fault.affected_detectors, vec![0]); + assert_eq!(x_fault.affected_observables, vec![0]); + assert!(x_fault.affected_tracked_paulis.is_empty()); + + let tracked_h_loc = catalog + .locations + .iter() + .find(|loc| loc.gate_type == GateType::H && loc.qubits.as_slice() == [1]) + .unwrap(); + let tracked_x_fault = tracked_h_loc + .faults + .iter() + .find(|fault| fault.pauli.as_ref() == Some(&PauliString::x(1))) + .unwrap(); + assert!(tracked_x_fault.affected_measurements.is_empty()); + assert!(tracked_x_fault.affected_detectors.is_empty()); + assert!(tracked_x_fault.affected_observables.is_empty()); + assert_eq!(tracked_x_fault.affected_tracked_paulis, vec![0]); + + let meas_fault = catalog + .locations + .iter() + .find(|loc| loc.channel == FaultChannel::PMeas) + .and_then(|loc| loc.faults.first()) + .unwrap(); + assert_eq!(meas_fault.affected_measurements, vec![0]); + assert_eq!(meas_fault.affected_detectors, vec![0]); + assert_eq!(meas_fault.affected_observables, vec![0]); + assert!(meas_fault.affected_tracked_paulis.is_empty()); + + assert!(catalog.to_mechanisms().iter().any(|mechanism| { + mechanism + .alternatives + .iter() + .any(|alternative| alternative.as_slice() == [0]) + })); + } + + #[test] + fn test_catalog_two_qubit_propagation_keeps_output_kinds_distinct() { + fn assert_case(gate_type: GateType, tracked_pauli: PauliString) { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + match gate_type { + GateType::CX => { + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0)]); + } + GateType::CZ => { + tc.tick().cz(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0)]); + } + GateType::SWAP => { + tc.tick().swap(&[(QubitId(0), QubitId(1))]); + tc.tick().cx(&[(QubitId(1), QubitId(2))]); + tc.tick().mz(&[QubitId(2)]); + } + other => panic!("unexpected gate type {other:?}"), + } + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String(tc.num_measurements().to_string()), + ); + tc.add_detector_metadata(&[-1], None, Some("D0"), Some(0)) + .unwrap(); + tc.add_observable_metadata(&[-1], Some(0), Some("L0")) + .unwrap(); + tc.tracked_pauli_labeled("tracked", tracked_pauli); + + let catalog = build_fault_catalog( + &tc, + &StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + + let h_loc = catalog + .locations + .iter() + .find(|loc| loc.gate_type == GateType::H && loc.qubits.as_slice() == [0]) + .unwrap(); + let x_fault = h_loc + .faults + .iter() + .find(|fault| fault.pauli.as_ref() == Some(&PauliString::x(0))) + .unwrap(); + + assert_eq!(x_fault.affected_measurements, vec![0], "{gate_type:?}"); + assert_eq!(x_fault.affected_detectors, vec![0], "{gate_type:?}"); + assert_eq!(x_fault.affected_observables, vec![0], "{gate_type:?}"); + assert_eq!(x_fault.affected_tracked_paulis, vec![0], "{gate_type:?}"); + } + + // X0 before CX becomes X0 X1. + assert_case(GateType::CX, PauliString::z(1)); + // X0 before CZ becomes X0 Z1. + assert_case(GateType::CZ, PauliString::x(1)); + // X0 before SWAP becomes X1, then the extra CX maps it to X1 X2. + assert_case(GateType::SWAP, PauliString::z(1)); + } + + #[test] + fn test_catalog_all_two_qubit_cliffords_propagate_x_fault_measurement_support() { + fn apply_gate(tc: &mut TickCircuit, gate_type: GateType) { + match gate_type { + GateType::CX => { + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + } + GateType::CY => { + tc.tick().cy(&[(QubitId(0), QubitId(1))]); + } + GateType::CZ => { + tc.tick().cz(&[(QubitId(0), QubitId(1))]); + } + GateType::SXX => { + tc.tick().sxx(&[(QubitId(0), QubitId(1))]); + } + GateType::SXXdg => { + tc.tick().sxxdg(&[(QubitId(0), QubitId(1))]); + } + GateType::SYY => { + tc.tick().syy(&[(QubitId(0), QubitId(1))]); + } + GateType::SYYdg => { + tc.tick().syydg(&[(QubitId(0), QubitId(1))]); + } + GateType::SZZ => { + tc.tick().szz(&[(QubitId(0), QubitId(1))]); + } + GateType::SZZdg => { + tc.tick().szzdg(&[(QubitId(0), QubitId(1))]); + } + GateType::SWAP => { + tc.tick().swap(&[(QubitId(0), QubitId(1))]); + } + other => panic!("unexpected gate type {other:?}"), + } + } + + for (gate_type, expected_measurements) in [ + (GateType::CX, &[0usize, 1][..]), + (GateType::CY, &[0usize, 1][..]), + (GateType::CZ, &[0usize][..]), + (GateType::SXX, &[0usize][..]), + (GateType::SXXdg, &[0usize][..]), + (GateType::SYY, &[1usize][..]), + (GateType::SYYdg, &[1usize][..]), + (GateType::SZZ, &[0usize][..]), + (GateType::SZZdg, &[0usize][..]), + (GateType::SWAP, &[1usize][..]), + ] { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + apply_gate(&mut tc, gate_type); + tc.tick().mz(&[QubitId(0), QubitId(1)]); + + let catalog = build_fault_catalog( + &tc, + &StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + + let h_loc = catalog + .locations + .iter() + .find(|loc| loc.gate_type == GateType::H && loc.qubits.as_slice() == [0]) + .unwrap(); + let x_fault = h_loc + .faults + .iter() + .find(|fault| fault.pauli.as_ref() == Some(&PauliString::x(0))) + .unwrap(); + + assert_eq!( + x_fault.affected_measurements.as_slice(), + expected_measurements, + "{gate_type:?}" + ); + } + } + + #[test] + fn test_catalog_standard_cliffords_match_forward_pauli_oracle_for_all_alternatives() { + fn apply_single_gate(tc: &mut TickCircuit, gate_type: GateType) { + match gate_type { + GateType::X => { + tc.tick().x(&[QubitId(0)]); + } + GateType::Y => { + tc.tick().y(&[QubitId(0)]); + } + GateType::Z => { + tc.tick().z(&[QubitId(0)]); + } + GateType::H => { + tc.tick().h(&[QubitId(0)]); + } + GateType::SZ => { + tc.tick().sz(&[QubitId(0)]); + } + GateType::SZdg => { + tc.tick().szdg(&[QubitId(0)]); + } + GateType::SX => { + tc.tick().sx(&[QubitId(0)]); + } + GateType::SXdg => { + tc.tick().sxdg(&[QubitId(0)]); + } + GateType::SY => { + tc.tick().sy(&[QubitId(0)]); + } + GateType::SYdg => { + tc.tick().sydg(&[QubitId(0)]); + } + GateType::F => { + tc.tick().f(&[QubitId(0)]); + } + GateType::Fdg => { + tc.tick().fdg(&[QubitId(0)]); + } + other => panic!("unexpected single-qubit gate {other:?}"), + } + } + + fn apply_pair_gate(tc: &mut TickCircuit, gate_type: GateType) { + match gate_type { + GateType::CX => { + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + } + GateType::CY => { + tc.tick().cy(&[(QubitId(0), QubitId(1))]); + } + GateType::CZ => { + tc.tick().cz(&[(QubitId(0), QubitId(1))]); + } + GateType::SXX => { + tc.tick().sxx(&[(QubitId(0), QubitId(1))]); + } + GateType::SXXdg => { + tc.tick().sxxdg(&[(QubitId(0), QubitId(1))]); + } + GateType::SYY => { + tc.tick().syy(&[(QubitId(0), QubitId(1))]); + } + GateType::SYYdg => { + tc.tick().syydg(&[(QubitId(0), QubitId(1))]); + } + GateType::SZZ => { + tc.tick().szz(&[(QubitId(0), QubitId(1))]); + } + GateType::SZZdg => { + tc.tick().szzdg(&[(QubitId(0), QubitId(1))]); + } + GateType::SWAP => { + tc.tick().swap(&[(QubitId(0), QubitId(1))]); + } + other => panic!("unexpected two-qubit gate {other:?}"), + } + } + + fn pauli_type(pauli: Pauli) -> PauliType { + match pauli { + Pauli::X => PauliType::X, + Pauli::Y => PauliType::Y, + Pauli::Z => PauliType::Z, + Pauli::I => panic!("identity is not a fault alternative"), + } + } + + fn expected_effect( + pauli: &PauliString, + start: usize, + gates: &[GateLoc], + meas_positions: &HashMap, + tracked_paulis: &[PauliString], + ) -> PropagatedFaultEffect { + let terms: Vec<_> = pauli + .iter_pairs() + .map(|(p, q)| (pauli_type(p), q.index())) + .collect(); + match terms.as_slice() { + [(p, q)] => { + propagate_single_effect(*p, *q, start, gates, meas_positions, tracked_paulis) + } + [(p0, q0), (p1, q1)] => propagate_pair_effect( + [(*p0, *q0), (*p1, *q1)], + start, + gates, + meas_positions, + tracked_paulis, + ), + other => panic!("expected one- or two-qubit Pauli alternative, got {other:?}"), + } + } + + for gate_type in STANDARD_1Q_CLIFFORD_GATES { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + apply_single_gate(&mut tc, *gate_type); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String(tc.num_measurements().to_string()), + ); + tc.add_detector_metadata(&[-1], None, Some("D0"), Some(0)) + .unwrap(); + tc.add_observable_metadata(&[-1], Some(0), Some("L0")) + .unwrap(); + tc.tracked_pauli_labeled("tracked_x1", PauliString::x(1)); + tc.tracked_pauli_labeled("tracked_y1", PauliString::y(1)); + tc.tracked_pauli_labeled("tracked_z1", PauliString::z(1)); + + let (gates, meas_positions) = flatten_tick_circuit(&tc); + let tracked_paulis = parse_tracked_pauli_annotations(&tc); + let catalog = build_fault_catalog( + &tc, + &StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + let source_loc_idx = gates + .iter() + .position(|loc| loc.gate_type == GateType::H && loc.qubits.as_slice() == [0]) + .unwrap(); + let source_loc = catalog + .locations + .iter() + .find(|loc| { + loc.tick == gates[source_loc_idx].tick + && loc.gate_index == gates[source_loc_idx].gate_index + }) + .unwrap(); + + for fault in &source_loc.faults { + let pauli = fault.pauli.as_ref().unwrap(); + let effect = expected_effect( + pauli, + source_loc_idx + 1, + &gates, + &meas_positions, + &tracked_paulis, + ); + let measurements: Vec<_> = effect.affected_measurements.iter().copied().collect(); + assert_eq!( + fault.affected_measurements, measurements, + "{gate_type:?} {pauli:?}" + ); + assert_eq!( + fault.affected_detectors, measurements, + "{gate_type:?} {pauli:?}" + ); + assert_eq!( + fault.affected_observables, measurements, + "{gate_type:?} {pauli:?}" + ); + assert_eq!( + fault.affected_tracked_paulis, effect.affected_tracked_paulis, + "{gate_type:?} {pauli:?}" + ); + } + } + + for gate_type in STANDARD_2Q_CLIFFORD_GATES { + let mut tc = TickCircuit::new(); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + apply_pair_gate(&mut tc, *gate_type); + tc.tick() + .cx(&[(QubitId(0), QubitId(2)), (QubitId(1), QubitId(3))]); + tc.tick().mz(&[QubitId(0), QubitId(1)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String(tc.num_measurements().to_string()), + ); + tc.add_detector_metadata(&[-2], None, Some("D0"), Some(0)) + .unwrap(); + tc.add_detector_metadata(&[-1], None, Some("D1"), Some(1)) + .unwrap(); + tc.add_observable_metadata(&[-2], Some(0), Some("L0")) + .unwrap(); + tc.add_observable_metadata(&[-1], Some(1), Some("L1")) + .unwrap(); + tc.tracked_pauli_labeled("tracked_x2", PauliString::x(2)); + tc.tracked_pauli_labeled("tracked_y2", PauliString::y(2)); + tc.tracked_pauli_labeled("tracked_z2", PauliString::z(2)); + tc.tracked_pauli_labeled("tracked_x3", PauliString::x(3)); + tc.tracked_pauli_labeled("tracked_y3", PauliString::y(3)); + tc.tracked_pauli_labeled("tracked_z3", PauliString::z(3)); + + let (gates, meas_positions) = flatten_tick_circuit(&tc); + let tracked_paulis = parse_tracked_pauli_annotations(&tc); + let catalog = build_fault_catalog( + &tc, + &StochasticNoiseParams { + p1: 0.0, + p2: 0.03, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + let source_loc_idx = gates + .iter() + .position(|loc| loc.gate_type == GateType::CX && loc.qubits.as_slice() == [0, 1]) + .unwrap(); + let source_loc = catalog + .locations + .iter() + .find(|loc| { + loc.tick == gates[source_loc_idx].tick + && loc.gate_index == gates[source_loc_idx].gate_index + }) + .unwrap(); + + for fault in &source_loc.faults { + let pauli = fault.pauli.as_ref().unwrap(); + let effect = expected_effect( + pauli, + source_loc_idx + 1, + &gates, + &meas_positions, + &tracked_paulis, + ); + let measurements: Vec<_> = effect.affected_measurements.iter().copied().collect(); + assert_eq!( + fault.affected_measurements, measurements, + "{gate_type:?} {pauli:?}" + ); + assert_eq!( + fault.affected_detectors, measurements, + "{gate_type:?} {pauli:?}" + ); + assert_eq!( + fault.affected_observables, measurements, + "{gate_type:?} {pauli:?}" + ); + assert_eq!( + fault.affected_tracked_paulis, effect.affected_tracked_paulis, + "{gate_type:?} {pauli:?}" + ); + } + } + } + + #[test] + fn test_fault_configurations_xor_detectors_observables_and_tracked_paulis_separately() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0), QubitId(1)]); + tc.tick().cx(&[(QubitId(0), QubitId(2))]); + tc.tick().mz(&[QubitId(0), QubitId(1)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String(tc.num_measurements().to_string()), + ); + tc.add_detector_metadata(&[-2, -1], None, Some("D0"), Some(0)) + .unwrap(); + tc.add_observable_metadata(&[-2], Some(0), Some("L0")) + .unwrap(); + tc.tracked_pauli_labeled("tracked_z2", PauliString::z(2)); + + let catalog = build_fault_catalog( + &tc, + &StochasticNoiseParams { + p1: 1.0, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + let h0 = catalog + .locations + .iter() + .position(|loc| loc.gate_type == GateType::H && loc.qubits.as_slice() == [0]) + .unwrap(); + let h1 = catalog + .locations + .iter() + .position(|loc| loc.gate_type == GateType::H && loc.qubits.as_slice() == [1]) + .unwrap(); + let x0 = catalog.locations[h0] + .faults + .iter() + .position(|fault| fault.pauli.as_ref() == Some(&PauliString::x(0))) + .unwrap(); + let x1 = catalog.locations[h1] + .faults + .iter() + .position(|fault| fault.pauli.as_ref() == Some(&PauliString::x(1))) + .unwrap(); + + let config = catalog + .fault_configurations(2) + .find(|config| { + config.location_indices == [h0, h1] && config.alternative_indices == [x0, x1] + }) + .unwrap(); + + assert_eq!(catalog.locations[h0].faults[x0].affected_detectors, [0]); + assert_eq!(catalog.locations[h0].faults[x0].affected_observables, [0]); + assert_eq!( + catalog.locations[h0].faults[x0].affected_tracked_paulis, + [0] + ); + assert_eq!(catalog.locations[h1].faults[x1].affected_detectors, [0]); + assert!( + catalog.locations[h1].faults[x1] + .affected_observables + .is_empty() + ); + assert!( + catalog.locations[h1].faults[x1] + .affected_tracked_paulis + .is_empty() + ); + + assert_eq!(config.affected_measurements, [0, 1]); + assert!(config.affected_detectors.is_empty()); + assert_eq!(config.affected_observables, [0]); + assert_eq!(config.affected_tracked_paulis, [0]); + } + + #[test] + fn test_tracked_pauli_phase_is_ignored_for_flip_tracking() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tracked_pauli_labeled("plus_z0", PauliString::z(0)); + tc.tracked_pauli_labeled( + "minus_z0", + PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::MinusOne, + vec![(Pauli::Z, QubitId(0))], + ), + ); + + let tracked_paulis = parse_tracked_pauli_annotations(&tc); + assert_eq!(tracked_paulis.len(), 2); + assert!( + tracked_paulis + .iter() + .all(|op| op.phase() == pecos_core::QuarterPhase::PlusOne) + ); + assert_eq!(tracked_paulis[0], tracked_paulis[1]); + + let catalog = build_fault_catalog( + &tc, + &StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + + let h_loc = catalog + .locations + .iter() + .find(|loc| loc.gate_type == GateType::H) + .unwrap(); + let x_fault = h_loc + .faults + .iter() + .find(|fault| fault.pauli.as_ref() == Some(&PauliString::x(0))) + .unwrap(); + let z_fault = h_loc + .faults + .iter() + .find(|fault| fault.pauli.as_ref() == Some(&PauliString::z(0))) + .unwrap(); + + assert_eq!(x_fault.affected_tracked_paulis, vec![0, 1]); + assert_eq!(z_fault.affected_tracked_paulis, Vec::::new()); + } + + #[test] + fn test_structural_catalog_includes_zero_probability_locations() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let catalog = FaultCatalog::from_circuit(&tc).unwrap(); + assert_eq!(catalog.locations.len(), 2); + assert!( + catalog + .locations + .iter() + .all(|loc| loc.channel_probability.abs() < 1e-12) + ); + assert!( + catalog + .locations + .iter() + .all(|loc| (loc.no_fault_probability - 1.0).abs() < 1e-12) + ); + assert!( + catalog + .locations + .iter() + .flat_map(|loc| &loc.faults) + .all(|fault| fault.absolute_probability.abs() < 1e-12) + ); + assert!( + catalog + .locations + .iter() + .any(|loc| loc.channel == FaultChannel::P1) + ); + assert!( + catalog + .locations + .iter() + .any(|loc| loc.channel == FaultChannel::PMeas) + ); + } + + #[test] + fn test_parameterized_matches_direct_for_fully_nonzero_noise() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[QubitId(0)]); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String(r#"[{"records":[-1]}]"#.to_string()), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String(r#"[{"records":[-1]}]"#.to_string()), + ); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.02, + p_meas: 0.01, + p_prep: 0.004, + }; + let direct = build_fault_catalog(&tc, &noise).unwrap(); + let mut split = FaultCatalog::from_circuit(&tc).unwrap(); + split.with_noise(&noise); + + assert_eq!(direct.locations.len(), split.locations.len()); + for (a, b) in direct.locations.iter().zip(&split.locations) { + assert_eq!(a.tick, b.tick); + assert_eq!(a.gate_index, b.gate_index); + assert_eq!(a.gate_type, b.gate_type); + assert_eq!(a.qubits, b.qubits); + assert_eq!(a.channel, b.channel); + assert_close(a.channel_probability, b.channel_probability); + assert_close(a.no_fault_probability, b.no_fault_probability); + assert_eq!(a.num_alternatives, b.num_alternatives); + assert_eq!(a.faults.len(), b.faults.len()); + for (af, bf) in a.faults.iter().zip(&b.faults) { + assert_eq!(af.kind, bf.kind); + assert_eq!(af.pauli, bf.pauli); + assert_eq!(af.affected_measurements, bf.affected_measurements); + assert_eq!(af.affected_detectors, bf.affected_detectors); + assert_eq!(af.affected_observables, bf.affected_observables); + assert_eq!(af.affected_tracked_paulis, bf.affected_tracked_paulis); + assert_close(af.conditional_probability, bf.conditional_probability); + assert_close(af.absolute_probability, bf.absolute_probability); + } + } + } + + #[test] + fn test_with_noise_overwrites_previous_probabilities() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + + let mut catalog = FaultCatalog::from_circuit(&tc).unwrap(); + catalog.with_noise(&StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }); + catalog.with_noise(&StochasticNoiseParams { + p1: 0.09, + p2: 0.0, + p_meas: 0.02, + p_prep: 0.0, + }); + + let h_loc = catalog + .locations + .iter() + .find(|loc| loc.channel == FaultChannel::P1) + .unwrap(); + assert_close(h_loc.channel_probability, 0.09); + assert_close(h_loc.no_fault_probability, 0.91); + assert!( + h_loc + .faults + .iter() + .all(|fault| (fault.absolute_probability - 0.03).abs() < 1e-12) + ); + + let meas_loc = catalog + .locations + .iter() + .find(|loc| loc.channel == FaultChannel::PMeas) + .unwrap(); + assert_close(meas_loc.channel_probability, 0.02); + assert_close(meas_loc.faults[0].absolute_probability, 0.02); + } + + #[test] + fn test_sparse_channel_keeps_structure_but_filters_raw_mechanisms() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + + let noise = StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.02, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + assert_eq!(catalog.locations.len(), 2); + assert!( + catalog + .locations + .iter() + .any(|loc| loc.channel == FaultChannel::P1 && loc.channel_probability.abs() < 1e-12) + ); + + let mechanisms = catalog.to_mechanisms(); + assert_eq!(mechanisms.len(), 1); + assert_close(mechanisms[0].probability, 0.02); + assert_eq!(mechanisms[0].alternatives, vec![vec![0]]); + assert_eq!(mechanisms, build_fault_table(&tc, &noise).unwrap()); + } + + #[test] + fn test_fault_configurations_skip_zero_probability_fault_events() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + + let catalog = FaultCatalog::from_circuit(&tc).unwrap(); + assert_eq!(catalog.fault_configurations(0).count(), 1); + assert_eq!( + catalog.fault_configurations(1).count(), + 0, + "unparameterized structural catalogs should not yield zero-probability selected faults" + ); + + let mut parameterized = catalog.clone(); + parameterized.with_noise(&StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.02, + p_prep: 0.0, + }); + assert_eq!( + parameterized.fault_configurations(1).count(), + 1, + "only the nonzero measurement fault location should be yielded" + ); + assert_eq!( + parameterized + .fault_configurations(1) + .next() + .unwrap() + .location_indices, + vec![1] + ); + } + + #[test] + fn test_tracked_only_effect_stays_in_catalog_but_not_raw_mechanisms() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tracked_pauli_labeled("tracked_z0", PauliString::z(0)); + + let mut catalog = FaultCatalog::from_circuit(&tc).unwrap(); + catalog.with_noise(&StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }); + + let h_loc = catalog + .locations + .iter() + .find(|loc| loc.channel == FaultChannel::P1) + .unwrap(); + assert!(h_loc.faults.iter().any(|fault| { + fault.affected_measurements.is_empty() && !fault.affected_tracked_paulis.is_empty() + })); + assert!(catalog.to_mechanisms().is_empty()); + } + + #[test] + fn test_to_mechanisms_matches_old_build_fault_table() { + // The key invariant: catalog.to_mechanisms() must produce the same + // mechanisms as the old build_fault_table path for nonzero noise. + let mut tc = TickCircuit::new(); + tc.tick().pz(&[QubitId(0), QubitId(1)]); + tc.tick().h(&[QubitId(0)]); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0), QubitId(1)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("2".to_string()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let noise = StochasticNoiseParams { + p1: 0.01, + p2: 0.05, + p_meas: 0.02, + p_prep: 0.01, + }; + + // Old path (now a wrapper, but the output must match): + let old_mechanisms = build_fault_table(&tc, &noise).unwrap(); + + // New path: + let mut catalog = FaultCatalog::from_circuit(&tc).unwrap(); + catalog.with_noise(&noise); + let new_mechanisms = catalog.to_mechanisms(); + + assert_eq!( + old_mechanisms.len(), + new_mechanisms.len(), + "mechanism count must match" + ); + for (old, new) in old_mechanisms.iter().zip(&new_mechanisms) { + assert_close(old.probability, new.probability); + assert_eq!( + old.alternatives.len(), + new.alternatives.len(), + "alternative count must match" + ); + for (old_alt, new_alt) in old.alternatives.iter().zip(&new.alternatives) { + assert_eq!(old_alt, new_alt, "measurement effects must match"); + } + } + } + + #[test] + fn test_catalog_meas_prep_probabilities() { + // PZ(0) MZ(0): prep X fault goes directly to MZ (flips it) + let mut tc = TickCircuit::new(); + tc.tick().pz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let noise = StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.007, + p_prep: 0.003, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + + let prep = catalog + .locations + .iter() + .find(|l| l.faults.iter().any(|f| f.kind == FaultKind::PrepFlip)); + assert!(prep.is_some(), "Should have a prep fault location"); + let prep = prep.unwrap(); + assert!((prep.channel_probability - 0.003).abs() < 1e-10); + assert!(prep.faults[0].pauli.is_none()); + + let meas = catalog.locations.iter().find(|l| { + l.faults + .iter() + .any(|f| f.kind == FaultKind::MeasurementFlip) + }); + assert!(meas.is_some(), "Should have a measurement fault location"); + let meas = meas.unwrap(); + assert!((meas.channel_probability - 0.007).abs() < 1e-10); + assert!(meas.faults[0].pauli.is_none()); + } + + #[test] + fn test_catalog_separate_locations_same_detector_effect() { + // Two H gates on same qubit → two separate locations + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String(r#"[{"records": [-1]}]"#.to_string()), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let noise = StochasticNoiseParams { + p1: 0.01, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + + // Both H gates → separate locations even if they have the same detector effect + let h_locs: Vec<_> = catalog + .locations + .iter() + .filter(|l| l.gate_type == GateType::H) + .collect(); + assert_eq!( + h_locs.len(), + 2, + "Two H gates should produce two separate locations" + ); + } + + #[test] + fn test_catalog_full_configuration_probability() { + // H(0) MZ(0) with p1=0.03, p_meas=0.01. + // Two locations: H (3 alts) and MZ (1 alt). + // Pick alt 0 at H, no fault at MZ: + // P = (0.03/3) * (1 - 0.01) = 0.01 * 0.99 = 0.0099 + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".to_string()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String("[]".to_string()), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String("[]".to_string()), + ); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + assert_eq!(catalog.locations.len(), 2); // H + MZ + + let h_loc = &catalog.locations[0]; // H + let mz_loc = &catalog.locations[1]; // MZ + + // Pick first H alternative, no fault at MZ + let alt_prob = h_loc.faults[0].absolute_probability; // 0.03/3 = 0.01 + let no_mz_prob = mz_loc.no_fault_probability; // 1 - 0.01 = 0.99 + let config_prob = alt_prob * no_mz_prob; + + assert!((config_prob - 0.0099).abs() < 1e-10); + } + + // ---- fault_configurations iterator tests ---- + + #[test] + fn test_configurations_k0_one_no_fault_event() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".into()), + ); + tc.set_meta("detectors", pecos_quantum::Attribute::String("[]".into())); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + + let configs: Vec<_> = catalog.fault_configurations(0).collect(); + assert_eq!(configs.len(), 1); + let c = &configs[0]; + assert!(c.location_indices.is_empty()); + assert!(c.alternative_indices.is_empty()); + assert!(c.affected_measurements.is_empty()); + assert!(c.affected_detectors.is_empty()); + assert_close(c.selected_probability, 1.0); + // config_prob = product of all no_fault_probability + let expected: f64 = catalog + .locations + .iter() + .map(|l| l.no_fault_probability) + .product(); + assert!((c.configuration_probability - expected).abs() < 1e-12); + } + + #[test] + fn test_configurations_k1_matches_single_fault() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".into()), + ); + tc.set_meta("detectors", pecos_quantum::Attribute::String("[]".into())); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + + let configs: Vec<_> = catalog.fault_configurations(1).collect(); + // Total k=1 configs = sum of num_alternatives across all locations + let expected_count: usize = catalog.locations.iter().map(|l| l.num_alternatives).sum(); + assert_eq!(configs.len(), expected_count); + + // First config should match first location, first alternative + let c = &configs[0]; + assert_eq!(c.location_indices, vec![0]); + assert_eq!(c.alternative_indices, vec![0]); + let alt = &catalog.locations[0].faults[0]; + assert_eq!(c.affected_measurements, alt.affected_measurements); + assert!((c.selected_probability - alt.absolute_probability).abs() < 1e-12); + } + + #[test] + fn test_configurations_skip_zero_probability_structural_locations() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".into()), + ); + tc.set_meta("detectors", pecos_quantum::Attribute::String("[]".into())); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + assert_eq!(catalog.locations.len(), 2); + + let h_idx = catalog + .locations + .iter() + .position(|loc| loc.gate_type == GateType::H) + .unwrap(); + let mz_idx = catalog + .locations + .iter() + .position(|loc| loc.gate_type == GateType::MZ) + .unwrap(); + assert_close(catalog.locations[mz_idx].channel_probability, 0.0); + + let configs: Vec<_> = catalog.fault_configurations(1).collect(); + assert_eq!(configs.len(), 3); + assert!(configs.iter().all(|c| c.location_indices == vec![h_idx])); + assert!(configs.iter().all(|c| c.selected_probability > 0.0)); + assert!( + configs + .iter() + .all(|c| !c.location_indices.contains(&mz_idx)) + ); + assert_eq!(catalog.fault_configurations(2).count(), 0); + } + + #[test] + fn test_configurations_all_zero_noise_only_yields_k0() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".into()), + ); + tc.set_meta("detectors", pecos_quantum::Attribute::String("[]".into())); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let catalog = build_fault_catalog( + &tc, + &StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + + let k0: Vec<_> = catalog.fault_configurations(0).collect(); + assert_eq!(k0.len(), 1); + assert_close(k0[0].configuration_probability, 1.0); + assert_eq!(catalog.fault_configurations(1).count(), 0); + assert_eq!(catalog.fault_configurations(2).count(), 0); + } + + #[test] + fn test_configurations_include_nonzero_silent_faults() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("0".into()), + ); + tc.set_meta("detectors", pecos_quantum::Attribute::String("[]".into())); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let catalog = build_fault_catalog( + &tc, + &StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + ) + .unwrap(); + + let configs: Vec<_> = catalog.fault_configurations(1).collect(); + assert_eq!(configs.len(), 3); + assert!(configs.iter().all(|c| c.affected_measurements.is_empty())); + assert!(configs.iter().all(|c| c.affected_detectors.is_empty())); + assert!(configs.iter().all(|c| c.affected_observables.is_empty())); + assert!(configs.iter().all(|c| c.selected_probability > 0.0)); + } + + #[test] + fn test_configurations_with_noise_zeroes_previous_channel() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".into()), + ); + tc.set_meta("detectors", pecos_quantum::Attribute::String("[]".into())); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let mut catalog = FaultCatalog::from_circuit(&tc).unwrap(); + catalog.with_noise(&StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }); + catalog.with_noise(&StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.02, + p_prep: 0.0, + }); + + let mz_idx = catalog + .locations + .iter() + .position(|loc| loc.gate_type == GateType::MZ) + .unwrap(); + let configs: Vec<_> = catalog.fault_configurations(1).collect(); + assert_eq!(configs.len(), 1); + assert_eq!(configs[0].location_indices, vec![mz_idx]); + assert_close(configs[0].selected_probability, 0.02); + } + + #[test] + fn test_configurations_k2_xor_cancels_duplicate_effects() { + // Two H gates both flipping measurement 0 → XOR cancels + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".into()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String(r#"[{"records":[-1]}]"#.into()), + ); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + assert_eq!(catalog.locations.len(), 3); // two H locations + structural MZ location + + // Find a k=2 config where both locations fire with Z alternative (flips MZ) + // Z after first H → X at second H → X at MZ → flips meas 0 + // Z after second H → Z at MZ → doesn't flip + // So to get XOR cancel: need two alternatives that BOTH flip meas 0 + let configs: Vec<_> = catalog.fault_configurations(2).collect(); + // Check that some configs have empty affected_measurements (XOR cancel) + let cancelled: Vec<_> = configs + .iter() + .filter(|c| c.affected_measurements.is_empty()) + .collect(); + assert!(!cancelled.is_empty(), "Some k=2 configs should XOR-cancel"); + } + + #[test] + fn test_configurations_k2_probability_hand_calc() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".into()), + ); + tc.set_meta("detectors", pecos_quantum::Attribute::String("[]".into())); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + // 2 locations: H (3 alts, p=0.03) and MZ (1 alt, p=0.01) + + let configs: Vec<_> = catalog.fault_configurations(2).collect(); + // k=2 means both locations fire + // selected_probability = (0.03/3) * (0.01/1) = 0.01 * 0.01 = 0.0001 + // configuration_probability = selected * (no unselected) = 0.0001 + assert_eq!(configs.len(), 3); // 3 alternatives at H × 1 at MZ + for c in &configs { + assert!((c.selected_probability - 0.0001).abs() < 1e-12); + assert!((c.configuration_probability - 0.0001).abs() < 1e-12); + } + } + + #[test] + fn test_configurations_all_fault_weights_sum_to_one() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[QubitId(0)]); + tc.tick().h(&[QubitId(0)]); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(1)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("2".into()), + ); + tc.set_meta("detectors", pecos_quantum::Attribute::String("[]".into())); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let noise = StochasticNoiseParams { + p1: 0.01, + p2: 0.05, + p_meas: 0.02, + p_prep: 0.01, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + + let total: f64 = (0..=catalog.locations.len()) + .flat_map(|k| catalog.fault_configurations(k)) + .map(|c| c.configuration_probability) + .sum(); + + assert!( + (total - 1.0).abs() < 1e-12, + "all truncated-by-k configurations across k=0..N should sum to 1, got {total}" + ); + } + + #[test] + fn test_configurations_iterator_is_lazy() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".into()), + ); + tc.set_meta("detectors", pecos_quantum::Attribute::String("[]".into())); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + + // Take only first 2 items from k=1 iterator (doesn't allocate all) + let first_two: Vec<_> = catalog.fault_configurations(1).take(2).collect(); + assert_eq!(first_two.len(), 2); + } + + // ---- RawMeasurementPlan tests ---- + + #[test] + fn test_plan_bell_r_source_shared_by_copy() { + // Bell: H(0) CX(0,1) MZ(0) MZ(1) + // m0 = Random, m1 = Copy(m0). Both share the same r-source. + // With zero noise, m0 == m1 for all shots. + use pecos_simulators::SymbolicSparseStab; + + let mut sim = SymbolicSparseStab::new(2); + sim.h(&[0]).cx(&[(0, 1)]); + sim.mz(&[0]); + sim.mz(&[1]); + + let plan = RawMeasurementPlan::new(sim.measurement_history(), vec![]); + let result = plan.sample(1000, 42); + + for shot in 0..1000 { + let m0 = result.get(shot, 0).0; + let m1 = result.get(shot, 1).0; + assert_eq!(m0, m1, "Bell pair: m0 must equal m1 (shot {shot})"); + } + } + + #[test] + fn test_plan_physical_fault_does_not_inherit_copy() { + // Bell: m0 = Random, m1 = Copy(m0). + // Add a physical fault that flips ONLY m0 with p=1. + // Result: m0 is flipped, m1 is NOT — the fault does not propagate + // through the ideal Copy dependency. + use pecos_simulators::SymbolicSparseStab; + + let mut sim = SymbolicSparseStab::new(2); + sim.h(&[0]).cx(&[(0, 1)]); + sim.mz(&[0]); + sim.mz(&[1]); + + // Fault that always fires, flipping only m0 + let mechanisms = vec![FaultMechanism { + probability: 1.0, + alternatives: vec![vec![0]], + }]; + let plan = RawMeasurementPlan::new(sim.measurement_history(), mechanisms); + let result = plan.sample(1000, 42); + + for shot in 0..1000 { + let m0 = result.get(shot, 0).0; + let m1 = result.get(shot, 1).0; + // m0 = base XOR 1 (always flipped), m1 = base (not flipped) + // Since base m0 == base m1, after flip: m0 != m1 + assert_ne!(m0, m1, "Fault on m0 must not inherit to m1 (shot {shot})"); + } + } + + #[test] + fn test_plan_grouped_alternatives_preserve_empty() { + // Deterministic base (m0 = Fixed(false) = always 0) with a p=1 mechanism + // having 3 alternatives: [flip m0, flip m0, no-op]. + // Each shot fires and picks one uniformly → 2/3 get flipped. + use pecos_simulators::SymbolicSparseStab; + + let mut sim = SymbolicSparseStab::new(1); + sim.mz(&[0]); // m0 = Fixed(false) + + let mechanisms = vec![FaultMechanism { + probability: 1.0, + alternatives: vec![vec![0], vec![0], vec![]], + }]; + let plan = RawMeasurementPlan::new(sim.measurement_history(), mechanisms); + let result = plan.sample(9000, 42); + + // base=0, fault flips with prob 2/3 → mean should be ~2/3. + let ones: usize = (0..9000).filter(|&s| result.get(s, 0).0).count(); + let mean = f64::from(u32::try_from(ones).expect("sample count fits in u32")) / 9000.0; + assert!( + (mean - 2.0 / 3.0).abs() < 0.03, + "Expected ~2/3 flip rate from grouped alternatives, got {mean:.4}" + ); + } + + #[test] + fn test_plan_geometric_sampling_firing_rates() { + use pecos_simulators::SymbolicSparseStab; + + let mut sim = SymbolicSparseStab::new(1); + sim.mz(&[0]); // deterministic base measurement: m0 = 0 + + let shots = 200_000usize; + for (p, low, high) in [ + (0.001, 120, 280), + (0.05, 9400, 10600), + (0.5, 99_000, 101_000), + ] { + let mechanisms = vec![FaultMechanism { + probability: p, + alternatives: vec![vec![0]], + }]; + let plan = RawMeasurementPlan::new(sim.measurement_history(), mechanisms); + let result = plan.sample(shots, 42); + + let firing_count = (0..shots).filter(|&shot| result.get(shot, 0).0).count(); + assert!( + (low..=high).contains(&firing_count), + "p={p} firing count {firing_count} outside expected range [{low}, {high}]" + ); + } + } + + #[test] + fn test_sample_raw_word_boundaries_are_masked() { + use pecos_simulators::SymbolicSparseStab; + + let mut sim = SymbolicSparseStab::new(1); + sim.mz(&[0]); // deterministic base measurement: m0 = 0 + + let mechanisms = vec![FaultMechanism { + probability: 1.0, + alternatives: vec![vec![0]], + }]; + let plan = RawMeasurementPlan::new(sim.measurement_history(), mechanisms); + + for shots in [63usize, 64, 65, 128, 129] { + let raw = plan.sample_raw(shots, 42); + let expected_words = shots.div_ceil(64); + assert_eq!(raw.columns[0].len(), expected_words); + for shot in 0..shots { + let word_idx = shot / 64; + let bit_idx = shot % 64; + assert_ne!( + raw.columns[0][word_idx] & (1u64 << bit_idx), + 0, + "shot {shot} should be flipped for p=1" + ); + } + + let remainder = shots % 64; + if remainder != 0 { + let tail_mask = !((1u64 << remainder) - 1); + assert_eq!( + raw.columns[0].last().copied().unwrap() & tail_mask, + 0, + "bits beyond {shots} shots should be masked off" + ); + } + } + } + + #[test] + fn test_sample_raw_masks_final_word_no_mechanisms() { + // 100 shots (not a multiple of 64): final word should have bits 100..128 = 0 + use pecos_simulators::SymbolicSparseStab; + + let mut sim = SymbolicSparseStab::new(1); + sim.h(&[0]); + sim.mz(&[0]); // Random + + let plan = RawMeasurementPlan::new(sim.measurement_history(), vec![]); + let raw = plan.sample_raw(100, 42); + + // 100 shots → 2 words. Last word should have bits 36..63 = 0 (100 - 64 = 36 valid bits) + assert_eq!(raw.columns[0].len(), 2); + let last_word = raw.columns[0][1]; + let valid_bits = 100 - 64; + let tail_mask = !((1u64 << valid_bits) - 1); + assert_eq!( + last_word & tail_mask, + 0, + "Bits beyond shots should be zero in measurement columns" + ); + } + + #[test] + fn test_sample_raw_r_columns_masked() { + use pecos_simulators::SymbolicSparseStab; + + let mut sim = SymbolicSparseStab::new(1); + sim.h(&[0]); + sim.mz(&[0]); // Random + + let plan = RawMeasurementPlan::new(sim.measurement_history(), vec![]); + let raw = plan.sample_raw(100, 42); + + assert_eq!(raw.r_columns.len(), 1); + assert_eq!(raw.r_columns[0].len(), 2); + let last_word = raw.r_columns[0][1]; + let valid_bits = 100 - 64; + let tail_mask = !((1u64 << valid_bits) - 1); + assert_eq!( + last_word & tail_mask, + 0, + "Bits beyond shots should be zero in r_columns" + ); + } + + #[test] + fn test_sample_raw_bell_r_source_mapping() { + // Bell: H(0) CX(0,1) MZ(0) MZ(1) + // m0=Random, m1=Copy(m0) → exactly one r-source at measurement 0 + use pecos_simulators::SymbolicSparseStab; + + let mut sim = SymbolicSparseStab::new(2); + sim.h(&[0]).cx(&[(0, 1)]); + sim.mz(&[0]); + sim.mz(&[1]); + + let plan = RawMeasurementPlan::new(sim.measurement_history(), vec![]); + let raw = plan.sample_raw(64, 42); + + assert_eq!(raw.r_columns.len(), 1, "Bell pair has one r-source"); + assert_eq!( + raw.r_source_measurements, + vec![0], + "r-source introduced at measurement 0" + ); + // The r column should equal the m0 column (since m0 = Random = r0 directly) + assert_eq!(raw.r_columns[0], raw.columns[0]); + // And m1 = Copy(m0), so columns[1] == columns[0] + assert_eq!(raw.columns[0], raw.columns[1]); + } + + #[test] + fn test_sample_raw_zero_shots_invariant() { + // Bell circuit with zero shots: r_columns length must match r_source_measurements + use pecos_simulators::SymbolicSparseStab; + + let mut sim = SymbolicSparseStab::new(2); + sim.h(&[0]).cx(&[(0, 1)]); + sim.mz(&[0]); + sim.mz(&[1]); + + let plan = RawMeasurementPlan::new(sim.measurement_history(), vec![]); + let raw = plan.sample_raw(0, 42); + + assert_eq!(raw.columns.len(), 2); + assert!(raw.columns[0].is_empty()); + assert!(raw.columns[1].is_empty()); + assert_eq!(raw.r_source_measurements, vec![0]); + assert_eq!(raw.r_columns.len(), 1); + assert!(raw.r_columns[0].is_empty()); + assert_eq!(raw.shots, 0); + } +} diff --git a/crates/pecos-qec/src/fault_tolerance/gadget_checker.rs b/crates/pecos-qec/src/fault_tolerance/gadget_checker.rs index 69f598fe6..794cf541a 100644 --- a/crates/pecos-qec/src/fault_tolerance/gadget_checker.rs +++ b/crates/pecos-qec/src/fault_tolerance/gadget_checker.rs @@ -963,13 +963,31 @@ impl<'a> GadgetChecker<'a> { /// Propagate a `PauliProp` through the circuit without additional faults. fn propagate_through_circuit(&self, mut prop: PauliProp) -> PauliProp { for tick in self.circuit.ticks() { - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { let qubits: Vec = gate.qubits.to_vec(); match gate.gate_type { pecos_core::gate_type::GateType::H => { prop.h(&qubits); } + pecos_core::gate_type::GateType::F => { + prop.f(&qubits); + } + pecos_core::gate_type::GateType::Fdg => { + prop.fdg(&qubits); + } + pecos_core::gate_type::GateType::SX => { + prop.sx(&qubits); + } + pecos_core::gate_type::GateType::SXdg => { + prop.sxdg(&qubits); + } + pecos_core::gate_type::GateType::SY => { + prop.sy(&qubits); + } + pecos_core::gate_type::GateType::SYdg => { + prop.sydg(&qubits); + } pecos_core::gate_type::GateType::SZ => { prop.sz(&qubits); } @@ -979,9 +997,33 @@ impl<'a> GadgetChecker<'a> { pecos_core::gate_type::GateType::CX if qubits.len() >= 2 => { prop.cx(&[(qubits[0], qubits[1])]); } + pecos_core::gate_type::GateType::CY if qubits.len() >= 2 => { + prop.cy(&[(qubits[0], qubits[1])]); + } pecos_core::gate_type::GateType::CZ if qubits.len() >= 2 => { prop.cz(&[(qubits[0], qubits[1])]); } + pecos_core::gate_type::GateType::SXX if qubits.len() >= 2 => { + prop.sxx(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SXXdg if qubits.len() >= 2 => { + prop.sxxdg(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SYY if qubits.len() >= 2 => { + prop.syy(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SYYdg if qubits.len() >= 2 => { + prop.syydg(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SZZ if qubits.len() >= 2 => { + prop.szz(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SZZdg if qubits.len() >= 2 => { + prop.szzdg(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SWAP if qubits.len() >= 2 => { + prop.swap(&[(qubits[0], qubits[1])]); + } pecos_core::gate_type::GateType::X => { prop.x(&qubits); } @@ -1677,7 +1719,7 @@ impl<'a> GadgetChecker<'a> { } // Propagate through circuit up to max_tick, injecting faults as we go - for (tick_idx, tick) in self.circuit.ticks().iter().enumerate() { + for (tick_idx, tick) in self.circuit.iter_ticks() { if tick_idx > max_tick { break; } @@ -1699,12 +1741,30 @@ impl<'a> GadgetChecker<'a> { } // Apply gates - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { let qubits: Vec = gate.qubits.to_vec(); match gate.gate_type { pecos_core::gate_type::GateType::H => { prop.h(&qubits); } + pecos_core::gate_type::GateType::F => { + prop.f(&qubits); + } + pecos_core::gate_type::GateType::Fdg => { + prop.fdg(&qubits); + } + pecos_core::gate_type::GateType::SX => { + prop.sx(&qubits); + } + pecos_core::gate_type::GateType::SXdg => { + prop.sxdg(&qubits); + } + pecos_core::gate_type::GateType::SY => { + prop.sy(&qubits); + } + pecos_core::gate_type::GateType::SYdg => { + prop.sydg(&qubits); + } pecos_core::gate_type::GateType::SZ => { prop.sz(&qubits); } @@ -1714,9 +1774,33 @@ impl<'a> GadgetChecker<'a> { pecos_core::gate_type::GateType::CX if qubits.len() >= 2 => { prop.cx(&[(qubits[0], qubits[1])]); } + pecos_core::gate_type::GateType::CY if qubits.len() >= 2 => { + prop.cy(&[(qubits[0], qubits[1])]); + } pecos_core::gate_type::GateType::CZ if qubits.len() >= 2 => { prop.cz(&[(qubits[0], qubits[1])]); } + pecos_core::gate_type::GateType::SXX if qubits.len() >= 2 => { + prop.sxx(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SXXdg if qubits.len() >= 2 => { + prop.sxxdg(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SYY if qubits.len() >= 2 => { + prop.syy(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SYYdg if qubits.len() >= 2 => { + prop.syydg(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SZZ if qubits.len() >= 2 => { + prop.szz(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SZZdg if qubits.len() >= 2 => { + prop.szzdg(&[(qubits[0], qubits[1])]); + } + pecos_core::gate_type::GateType::SWAP if qubits.len() >= 2 => { + prop.swap(&[(qubits[0], qubits[1])]); + } pecos_core::gate_type::GateType::X => { prop.x(&qubits); } @@ -1942,7 +2026,8 @@ mod tests { let mut circuit = TickCircuit::new(); circuit.tick().pz(&[0, 1, 2]); // Initialize all data qubits circuit.tick().h(&[0]); // Some operations - circuit.tick().cx(&[(0, 1), (0, 2)]); + circuit.tick().cx(&[(0, 1)]); + circuit.tick().cx(&[(0, 2)]); // Data qubits are OUTPUT (not measured) circuit } @@ -2347,7 +2432,8 @@ mod tests { let mut circuit = TickCircuit::new(); circuit.tick().pz(&[0, 1, 2]); // All qubits prepared circuit.tick().h(&[0]); - circuit.tick().cx(&[(0, 1), (0, 2)]); + circuit.tick().cx(&[(0, 1)]); + circuit.tick().cx(&[(0, 2)]); // No measurement - outputs go to next stage let checker = GadgetChecker::from_circuit(&circuit); diff --git a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs index 4774ef4b0..aa77d8e71 100644 --- a/crates/pecos-qec/src/fault_tolerance/influence_builder.rs +++ b/crates/pecos-qec/src/fault_tolerance/influence_builder.rs @@ -10,7 +10,7 @@ //! //! ``` //! use pecos_qec::fault_tolerance::InfluenceBuilder; -//! use pecos_qec::fault_tolerance::noisy_sampler::{NoisySampler, UniformNoiseModel}; +//! use pecos_qec::fault_tolerance::dem_builder::DemSampler; //! use pecos_quantum::DagCircuit; //! //! // Build a syndrome extraction circuit @@ -24,13 +24,13 @@ //! let builder = InfluenceBuilder::new(&dag); //! let influence_map = builder.build(); //! -//! // Use with CPU sampler -//! let noise = UniformNoiseModel::depolarizing(0.001); -//! let mut sampler = NoisySampler::new(&influence_map, noise, 42); -//! let results = sampler.sample(100); +//! // Build a fast DemSampler from the influence map +//! let num_locations = influence_map.locations.len(); +//! let sampler = DemSampler::from_influence_map(&influence_map, &vec![0.001; num_locations]); +//! let stats = sampler.sample_statistics(100, 42); //! ``` -use super::propagator::dag::{DagFaultInfluenceMap, DagSpacetimeLocation}; +use super::propagator::dag::{DagFaultInfluenceMap, DagSpacetimeLocation, DemOutputMetadata}; use super::propagator::types::{DetectorId, MeasurementId}; use super::propagator::{DagFaultAnalyzer, DagPropagator, Direction, Pauli, apply_gate}; use pecos_core::QubitId; @@ -38,15 +38,61 @@ use pecos_simulators::{PauliProp, SymbolicSparseStab}; use smallvec::SmallVec; use std::collections::BinaryHeap; +struct ObservablePropagationWork<'a> { + recorder: &'a mut CompoundRecorder, + visited: &'a mut [bool], + active_qubits: &'a mut [bool], + heap: &'a mut BinaryHeap<(usize, usize)>, +} + /// Builder for fault influence maps with proper detector definitions. /// /// This integrates forward symbolic simulation with backward propagation /// to create complete influence maps suitable for noisy sampling. +/// Re-export `PauliString` as the type used for Pauli operator tracking. +/// +/// All circuit annotations (detectors, observables, tracked Paulis) are Pauli +/// strings tracked for flipping via backward propagation. The difference +/// is role and readout: +/// +/// | Kind | Meaning | Readout | API | +/// |------|---------|---------|-----| +/// | Detector | Syndrome parity from measurements | measurement XOR = 0 | `dag.detector(&[...])` | +/// | Observable | Standard `L` output from measurements | measurement XOR | `dag.observable(&[...])` | +/// | Tracked Pauli | User Pauli string annotated at a circuit point | fault anticommutes with tracked Pauli | `dag.tracked_pauli(&[...])` | +/// +/// Observables and tracked Paulis both use backward Pauli propagation, but +/// they are not the same concept. Observables are values observed through +/// measurements, are defined by measurement records, and are decoder-visible +/// `L` outputs. Tracked Paulis are not measured and are not applied to the +/// computation; they ask whether a fault would flip the annotated Pauli placed as +/// an annotation in the circuit, such as a logical operator, stabilizer, or +/// other Pauli of interest. They live in a separate PECOS-only namespace. +pub use pecos_core::PauliString; + +struct NonDetectorOutputTarget { + metadata: DemOutputMetadata, + terms: Vec, +} + +struct PauliPropagationTerm { + pauli: PauliString, + start_node: Option, +} + pub struct InfluenceBuilder<'a> { dag: &'a pecos_quantum::DagCircuit, - /// Logical operators to track (qubit indices with X or Z component) - logical_x_qubits: Vec, - logical_z_qubits: Vec, + /// Non-detector parity outputs to track for flipping. + /// + /// This internal list contains both standard observables and PECOS tracked + /// operators. The metadata kind is the authority for which public namespace + /// each entry belongs to; callers should not infer that from the raw index. + /// + /// Each entry has one metadata item and one or more propagation terms. + /// Multiple terms accumulate into the same output index, which is needed + /// for measurement-record observables whose measurements occur at different + /// circuit positions. + non_detector_outputs: Vec, } impl<'a> InfluenceBuilder<'a> { @@ -55,26 +101,133 @@ impl<'a> InfluenceBuilder<'a> { pub fn new(dag: &'a pecos_quantum::DagCircuit) -> Self { Self { dag, - logical_x_qubits: Vec::new(), - logical_z_qubits: Vec::new(), + non_detector_outputs: Vec::new(), } } - /// Add a logical X operator to track. + /// Add a tracked X Pauli (X on all specified qubits). + #[must_use] + pub fn with_x(mut self, qubits: &[usize]) -> Self { + self.push_single_term_output( + DemOutputMetadata::tracked_pauli(PauliString::xs(qubits)), + None, + ); + self + } + + /// Add a tracked Z Pauli (Z on all specified qubits). + #[must_use] + pub fn with_z(mut self, qubits: &[usize]) -> Self { + self.push_single_term_output( + DemOutputMetadata::tracked_pauli(PauliString::zs(qubits)), + None, + ); + self + } + + /// Add a tracked Y Pauli (Y on all specified qubits). + #[must_use] + pub fn with_y(mut self, qubits: &[usize]) -> Self { + self.push_single_term_output( + DemOutputMetadata::tracked_pauli(PauliString::ys(qubits)), + None, + ); + self + } + + /// Add a Pauli check: track whether this Pauli string flips due to faults. + /// + /// Unlike observables (`dag.observable()`), a Pauli check + /// uses backward propagation to detect flips WITHOUT requiring a measurement. + /// + /// # Example /// - /// The logical X is defined as X on all specified qubits. + /// ``` + /// // Check if Y = X_0 * Z_1 * Z_2 flips + /// use pecos_core::{Pauli, PauliString}; + /// use pecos_qec::fault_tolerance::InfluenceBuilder; + /// use pecos_quantum::DagCircuit; + /// + /// let dag = DagCircuit::new(); + /// let builder = InfluenceBuilder::new(&dag).with_tracked_pauli( + /// PauliString::from_paulis(&[Pauli::X, Pauli::Z, Pauli::Z]), + /// ); + /// let _map = builder.build(); + /// ``` #[must_use] - pub fn with_logical_x(mut self, qubits: Vec) -> Self { - self.logical_x_qubits = qubits; + pub fn with_tracked_pauli(mut self, pauli: PauliString) -> Self { + self.push_single_term_output(DemOutputMetadata::tracked_pauli(pauli), None); self } - /// Add a logical Z operator to track. + fn push_single_term_output(&mut self, metadata: DemOutputMetadata, start_node: Option) { + self.non_detector_outputs.push(NonDetectorOutputTarget { + terms: vec![PauliPropagationTerm { + pauli: metadata.pauli.clone(), + start_node, + }], + metadata, + }); + } + + /// Extract observable and tracked-Pauli annotations from the circuit. + /// + /// Observable annotations define logical observables via measurement records. + /// For backward propagation, each referenced measurement contributes its + /// own Z-type propagation term starting at that measurement node. The terms + /// accumulate into the same observable `L` output. /// - /// The logical Z is defined as Z on all specified qubits. + /// Tracked-Pauli annotations have a corresponding `TrackedPauliMeta` node + /// that marks their time position. + /// + /// Detector annotations are NOT handled here -- they are processed + /// by `DemSamplerBuilder::with_circuit_annotations` which maps them + /// to auto-detected detectors. #[must_use] - pub fn with_logical_z(mut self, qubits: Vec) -> Self { - self.logical_z_qubits = qubits; + pub fn with_circuit_annotations(mut self, circuit: &pecos_quantum::DagCircuit) -> Self { + // Find TrackedPauliMeta nodes in topological order. + // The nth meta-gate corresponds to the nth tracked-Pauli annotation. + let meta_nodes: Vec = circuit + .topological_order() + .into_iter() + .filter(|&node| circuit.gate(node).is_some_and(|g| g.gate_type.is_meta())) + .collect(); + + let mut operator_idx = 0; + for ann in circuit.annotations() { + match &ann.kind { + pecos_quantum::AnnotationKind::Observable { measurement_nodes } => { + let mut terms = Vec::new(); + for &meas_node in measurement_nodes { + if let Some(gate) = circuit.gate(meas_node) { + let qubits: Vec = + gate.qubits.iter().map(pecos_core::QubitId::index).collect(); + terms.push(PauliPropagationTerm { + pauli: PauliString::zs(&qubits), + start_node: Some(meas_node), + }); + } + } + self.non_detector_outputs.push(NonDetectorOutputTarget { + metadata: DemOutputMetadata::observable(ann.pauli.clone()) + .with_optional_label(ann.label.clone()), + terms, + }); + } + pecos_quantum::AnnotationKind::TrackedPauli => { + let meta_node = meta_nodes.get(operator_idx).copied(); + operator_idx += 1; + self.push_single_term_output( + DemOutputMetadata::tracked_pauli(ann.pauli.clone()) + .with_optional_label(ann.label.clone()), + meta_node, + ); + } + pecos_quantum::AnnotationKind::Detector { .. } => { + // Detectors handled separately by DemSamplerBuilder + } + } + } self } @@ -83,7 +236,7 @@ impl<'a> InfluenceBuilder<'a> { /// This performs: /// 1. Forward symbolic simulation to get measurement correlations /// 2. Detector extraction from deterministic measurements - /// 3. Backward propagation from detectors and logicals + /// 3. Backward propagation from detectors and DEM outputs #[must_use] pub fn build(&self) -> DagFaultInfluenceMap { // Step 1: Run forward symbolic simulation @@ -98,10 +251,10 @@ impl<'a> InfluenceBuilder<'a> { /// Run symbolic simulation to get measurement correlations. fn run_symbolic_simulation(&self) -> MeasurementInfo { + let topo_order = self.dag.topological_order(); + // Determine number of qubits from the circuit - let max_qubit = self - .dag - .topological_order() + let max_qubit = topo_order .iter() .filter_map(|&node| self.dag.gate(node)) .flat_map(|op| op.qubits.iter()) @@ -113,11 +266,12 @@ impl<'a> InfluenceBuilder<'a> { let mut sim = SymbolicSparseStab::new(num_qubits); // Track node -> measurement index mapping - let mut node_to_meas_idx: Vec> = vec![None; self.dag.gate_count() + 1]; + let node_count = topo_order.iter().copied().max().map_or(0, |node| node + 1); + let mut node_to_meas_idx: Vec> = vec![None; node_count]; let mut meas_idx = 0; // Execute circuit symbolically - for &node in &self.dag.topological_order() { + for &node in &topo_order { if let Some(op) = self.dag.gate(node) { let qubits: Vec = op.qubits.iter().map(pecos_core::QubitId::index).collect(); @@ -125,14 +279,31 @@ impl<'a> InfluenceBuilder<'a> { pecos_quantum::GateType::H => { sim.h(&[qubits[0]]); } + pecos_quantum::GateType::F => { + sim.sx(&[qubits[0]]); + sim.sz(&[qubits[0]]); + } + pecos_quantum::GateType::Fdg => { + sim.szdg(&[qubits[0]]); + sim.sxdg(&[qubits[0]]); + } + pecos_quantum::GateType::SX => { + sim.sx(&[qubits[0]]); + } + pecos_quantum::GateType::SXdg => { + sim.sxdg(&[qubits[0]]); + } + pecos_quantum::GateType::SY => { + sim.sy(&[qubits[0]]); + } + pecos_quantum::GateType::SYdg => { + sim.sydg(&[qubits[0]]); + } pecos_quantum::GateType::SZ => { sim.sz(&[qubits[0]]); } pecos_quantum::GateType::SZdg => { - // SZdg = SZ^3 = SZ * SZ * SZ - sim.sz(&[qubits[0]]); - sim.sz(&[qubits[0]]); - sim.sz(&[qubits[0]]); + sim.szdg(&[qubits[0]]); } pecos_quantum::GateType::X => { sim.x(&[qubits[0]]); @@ -146,11 +317,32 @@ impl<'a> InfluenceBuilder<'a> { pecos_quantum::GateType::CX => { sim.cx(&[(qubits[0], qubits[1])]); } + pecos_quantum::GateType::CY => { + sim.cy(&[(qubits[0], qubits[1])]); + } pecos_quantum::GateType::CZ => { - // CZ = H(target) CX H(target) - sim.h(&[qubits[1]]); - sim.cx(&[(qubits[0], qubits[1])]); - sim.h(&[qubits[1]]); + sim.cz(&[(qubits[0], qubits[1])]); + } + pecos_quantum::GateType::SXX => { + sim.sxx(&[(qubits[0], qubits[1])]); + } + pecos_quantum::GateType::SXXdg => { + sim.sxxdg(&[(qubits[0], qubits[1])]); + } + pecos_quantum::GateType::SYY => { + sim.syy(&[(qubits[0], qubits[1])]); + } + pecos_quantum::GateType::SYYdg => { + sim.syydg(&[(qubits[0], qubits[1])]); + } + pecos_quantum::GateType::SZZ => { + sim.szz(&[(qubits[0], qubits[1])]); + } + pecos_quantum::GateType::SZZdg => { + sim.szzdg(&[(qubits[0], qubits[1])]); + } + pecos_quantum::GateType::SWAP => { + sim.swap(&[(qubits[0], qubits[1])]); } pecos_quantum::GateType::MZ | pecos_quantum::GateType::MeasureFree => { sim.mz(&[qubits[0]]); @@ -222,8 +414,9 @@ impl<'a> InfluenceBuilder<'a> { map.locations = Self::extract_locations(propagator); // Build measurement node lookup - let measurements = Self::extract_measurements(propagator); + let (measurements, meas_ids) = Self::extract_measurements(propagator); map.measurements.clone_from(&measurements); + map.meas_ids = meas_ids; // Create DetectorId entries for each detector for detector in detectors { @@ -256,31 +449,30 @@ impl<'a> InfluenceBuilder<'a> { }); } - // Add logical operators as additional "detectors" for tracking - let num_detectors = detectors.len(); - let mut num_logicals = 0; - - // Track logical X (sensitive to Z errors) - if !self.logical_x_qubits.is_empty() { - num_logicals += 1; - } - // Track logical Z (sensitive to X errors) - if !self.logical_z_qubits.is_empty() { - num_logicals += 1; - } - // Build the influence structure using backward propagation - let mut recorder = CompoundRecorder::new(map.locations.len(), num_detectors, num_logicals); + let mut recorder = CompoundRecorder::new(map.locations.len()); // Propagate from each detector Self::propagate_detectors(propagator, info, detectors, &mut recorder); - // Propagate from logicals - self.propagate_logicals(propagator, &mut recorder); + // Propagate from non-detector DEM outputs. + self.propagate_non_detector_outputs(propagator, &mut recorder); // Convert to SoA format map.influences = recorder.into_soa(); + // Store DEM-output labels + map.dem_output_labels = self + .non_detector_outputs + .iter() + .map(|output| output.metadata.label.clone()) + .collect(); + map.dem_output_metadata = self + .non_detector_outputs + .iter() + .map(|output| output.metadata.clone()) + .collect(); + map } @@ -290,43 +482,32 @@ impl<'a> InfluenceBuilder<'a> { for &node in propagator.topo_order() { if let Some(gate) = propagator.gate(node) { + // Meta-gates are not physical -- they don't generate faults + if gate.gate_type.is_meta() { + continue; + } + let qubits: Vec = gate.qubits.to_vec(); let is_measurement = matches!( gate.gate_type, pecos_quantum::GateType::MZ | pecos_quantum::GateType::MeasureFree ); - let is_prep = matches!( - gate.gate_type, - pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc - ); - if is_measurement { + // Standard circuit noise model: one fault location per gate. + // 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: qubits.clone(), - before: true, - gate_type: gate.gate_type, - }); - } else if is_prep { - locations.push(DagSpacetimeLocation { - node, - qubits: qubits.clone(), - before: false, - gate_type: gate.gate_type, - }); - } else { - locations.push(DagSpacetimeLocation { - node, - qubits: qubits.clone(), - before: true, - gate_type: gate.gate_type, - }); - locations.push(DagSpacetimeLocation { - node, - qubits, - before: false, + qubits: vec![q], + before, gate_type: gate.gate_type, + idle_duration, }); } } @@ -336,8 +517,10 @@ impl<'a> InfluenceBuilder<'a> { } /// Extract measurements from the propagator. - fn extract_measurements(propagator: &DagPropagator<'_>) -> Vec<(usize, usize, u8)> { - let mut measurements = Vec::new(); + fn extract_measurements( + propagator: &DagPropagator<'_>, + ) -> (Vec<(usize, usize, u8)>, Vec) { + let mut entries: Vec<(usize, usize, usize, u8, Option)> = Vec::new(); for &node in propagator.topo_order() { if let Some(gate) = propagator.gate(node) { @@ -346,13 +529,39 @@ impl<'a> InfluenceBuilder<'a> { _ => continue, }; - for qubit in &gate.qubits { - measurements.push((node, qubit.index(), basis)); + if gate.meas_ids.is_empty() { + let topo_pos = propagator.topo_position(node); + for qubit in &gate.qubits { + entries.push((topo_pos, node, qubit.index(), basis, None)); + } + } else { + for (i, qubit) in gate.qubits.iter().enumerate() { + let mr = gate.meas_ids.get(i).copied(); + let sort_key = mr.map_or(usize::MAX, pecos_core::MeasId::index); + entries.push((sort_key, node, qubit.index(), basis, mr)); + } } } } - measurements + entries.sort_by_key(|&(sort_key, _, qubit, _, _)| (sort_key, qubit)); + + let has_meas_ids = entries.iter().any(|(_, _, _, _, mr)| mr.is_some()); + let meas_ids = if has_meas_ids { + entries + .iter() + .map(|(_, _, _, _, mr)| mr.unwrap_or(pecos_core::MeasId(usize::MAX))) + .collect() + } else { + Vec::new() + }; + + let measurements = entries + .into_iter() + .map(|(_, node, qubit, basis, _)| (node, qubit, basis)) + .collect(); + + (measurements, meas_ids) } /// Propagate backward from all detectors. @@ -368,6 +577,12 @@ impl<'a> InfluenceBuilder<'a> { let mut visited = vec![false; max_node + 1]; let mut active_qubits = vec![false; max_qubit + 1]; let mut heap = BinaryHeap::new(); + let mut work = ObservablePropagationWork { + recorder, + visited: &mut visited, + active_qubits: &mut active_qubits, + heap: &mut heap, + }; for (det_idx, detector) in detectors.iter().enumerate() { // Build combined Pauli from all measurements in the detector @@ -394,93 +609,100 @@ impl<'a> InfluenceBuilder<'a> { &combined_prop, det_idx, true, // is_detector - recorder, - &mut visited, - &mut active_qubits, - &mut heap, + &mut work, + None, // detectors: walk from circuit end ); } } - /// Propagate backward from logical operators. - fn propagate_logicals(&self, propagator: &DagPropagator<'_>, recorder: &mut CompoundRecorder) { + /// Propagate backward from non-detector DEM outputs. + /// + /// If a propagation term has a corresponding DAG node, propagation starts + /// from that node's topological position. Otherwise (e.g. operators added + /// via `with_z`/`with_x` without a circuit annotation), propagation walks + /// from the circuit end. + fn propagate_non_detector_outputs( + &self, + propagator: &DagPropagator<'_>, + recorder: &mut CompoundRecorder, + ) { let max_node = propagator.max_node(); let max_qubit = propagator.max_qubit(); let mut visited = vec![false; max_node + 1]; let mut active_qubits = vec![false; max_qubit + 1]; let mut heap = BinaryHeap::new(); - let mut logical_idx = 0; - - // Logical X (product of X on specified qubits) - sensitive to Z errors - if !self.logical_x_qubits.is_empty() { - let mut prop = PauliProp::new(); - for &q in &self.logical_x_qubits { - prop.track_x(&[q]); - } - - Self::propagate_observable( - propagator, - &prop, - logical_idx, - false, // is_detector (this is a logical) - recorder, - &mut visited, - &mut active_qubits, - &mut heap, - ); - logical_idx += 1; - } + let mut work = ObservablePropagationWork { + recorder, + visited: &mut visited, + active_qubits: &mut active_qubits, + heap: &mut heap, + }; + + for (dem_output_idx, output) in self.non_detector_outputs.iter().enumerate() { + for term in &output.terms { + let mut prop = PauliProp::new(); + + for &(pauli, qubit) in term.pauli.paulis() { + use pecos_core::Pauli; + let q = qubit.index(); + match pauli { + Pauli::X => prop.track_x(&[q]), + Pauli::Y => prop.track_y(&[q]), + Pauli::Z => prop.track_z(&[q]), + Pauli::I => {} + } + } - // Logical Z (product of Z on specified qubits) - sensitive to X errors - if !self.logical_z_qubits.is_empty() { - let mut prop = PauliProp::new(); - for &q in &self.logical_z_qubits { - prop.track_z(&[q]); + // Resolve the term's node to its topological position. + // None means no positional bound (walk from circuit end). + let start_pos = term.start_node.map(|node| propagator.topo_position(node)); + + Self::propagate_observable( + propagator, + &prop, + dem_output_idx, + false, // is_detector = false (this is a DEM output) + &mut work, + start_pos, + ); } - - Self::propagate_observable( - propagator, - &prop, - logical_idx, - false, // is_detector (this is a logical) - recorder, - &mut visited, - &mut active_qubits, - &mut heap, - ); } } /// Propagate a single observable backward and record influences. - #[allow(clippy::too_many_arguments)] + /// + /// When `start_topo_pos` is `Some(pos)`, only gates at or before that + /// topological position are considered. This makes Pauli operator + /// annotations positional: only faults before the meta-gate affect it. fn propagate_observable( propagator: &DagPropagator<'_>, initial_prop: &PauliProp, target_idx: usize, is_detector: bool, - recorder: &mut CompoundRecorder, - visited: &mut [bool], - active_qubits: &mut [bool], - heap: &mut BinaryHeap<(usize, usize)>, + work: &mut ObservablePropagationWork<'_>, + start_topo_pos: Option, ) { // Clear work arrays - visited.fill(false); - active_qubits.fill(false); - heap.clear(); + work.visited.fill(false); + work.active_qubits.fill(false); + work.heap.clear(); let mut prop = initial_prop.clone(); // Initialize active qubits from the observable - for (q, is_active) in active_qubits.iter_mut().enumerate() { + for (q, is_active) in work.active_qubits.iter_mut().enumerate() { if prop.contains_x(q) || prop.contains_z(q) { *is_active = true; - // Add all gates on this qubit to the heap + // Add gates on this qubit to the heap, bounded by start position for (topo_pos, node) in propagator.qubit_gates_backward(q) { - if !visited[node] { - visited[node] = true; - heap.push((topo_pos, node)); + if start_topo_pos.is_some_and(|max| topo_pos > max) { + continue; + } + if !work.visited[node] { + work.visited[node] = true; + work.heap.push((topo_pos, node)); } } } @@ -490,44 +712,80 @@ impl<'a> InfluenceBuilder<'a> { let loc_map = Self::build_location_map(propagator); // Process gates in reverse topological order - while let Some((_, node)) = heap.pop() { + while let Some((_, node)) = work.heap.pop() { if let Some(gate) = propagator.gate(node) { - // Record influences at before=false location - if let Some(&loc_idx) = loc_map.get(&(node, false)) { - Self::record_influence(&prop, loc_idx, target_idx, is_detector, recorder); + // Record per-qubit influences at before=false location + if let Some(qubit_locs) = loc_map.get(&(node, false)) { + Self::record_influence( + &prop, + qubit_locs, + target_idx, + is_detector, + &mut *work.recorder, + ); } // Track which qubits were active before the gate let mut was_active = [false; 8]; for (j, q) in gate.qubits.iter().enumerate() { - if j < was_active.len() && q.index() < active_qubits.len() { - was_active[j] = active_qubits[q.index()]; + if j < was_active.len() && q.index() < work.active_qubits.len() { + was_active[j] = work.active_qubits[q.index()]; + } + } + + // Prep gates (PZ/QAlloc) reset the qubit -- kill the Pauli + // and mark the qubit inactive. Faults before the prep + // cannot propagate past it. + let is_prep = matches!( + gate.gate_type, + pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc + ); + if is_prep { + for q in &gate.qubits { + let qi = q.index(); + // Toggle off X and Z components (XOR to zero) + if prop.contains_x(qi) { + prop.track_x(&[qi]); + } + if prop.contains_z(qi) { + prop.track_z(&[qi]); + } + if qi < work.active_qubits.len() { + work.active_qubits[qi] = false; + } } + continue; // don't propagate further on these qubits } // Apply gate backward apply_gate(&mut prop, gate, Direction::Backward); - // Record influences at before=true location - if let Some(&loc_idx) = loc_map.get(&(node, true)) { - Self::record_influence(&prop, loc_idx, target_idx, is_detector, recorder); + // Record per-qubit influences at before=true location + if let Some(qubit_locs) = loc_map.get(&(node, true)) { + Self::record_influence( + &prop, + qubit_locs, + target_idx, + is_detector, + &mut *work.recorder, + ); } // Check if Pauli spread to new qubits let node_topo_pos = propagator.topo_position(node); for (j, q) in gate.qubits.iter().enumerate() { let idx = q.index(); - if idx < active_qubits.len() { + if idx < work.active_qubits.len() { let now_active = prop.contains_x(idx) || prop.contains_z(idx); let was = j < was_active.len() && was_active[j]; if now_active && !was { // Pauli spread to this qubit - add its gates - active_qubits[idx] = true; + work.active_qubits[idx] = true; for (topo_pos, pred_node) in propagator.qubit_gates_backward(idx) { - if topo_pos < node_topo_pos && !visited[pred_node] { - visited[pred_node] = true; - heap.push((topo_pos, pred_node)); + if topo_pos < node_topo_pos && !work.visited[pred_node] { + work.visited[pred_node] = true; + work.heap.push((topo_pos, pred_node)); } } } @@ -537,34 +795,30 @@ impl<'a> InfluenceBuilder<'a> { } } - /// Build a map from (node, before) to location index. + /// Build a map from (node, before) to per-qubit location indices. fn build_location_map( propagator: &DagPropagator<'_>, - ) -> std::collections::HashMap<(usize, bool), usize> { - let mut map = std::collections::HashMap::new(); + ) -> std::collections::HashMap<(usize, bool), Vec<(usize, usize)>> { + // (node, before) -> [(qubit_index, loc_idx), ...] + let mut map: std::collections::HashMap<(usize, bool), Vec<(usize, usize)>> = + std::collections::HashMap::new(); let mut loc_idx = 0; for &node in propagator.topo_order() { if let Some(gate) = propagator.gate(node) { + if gate.gate_type.is_meta() { + continue; + } + let is_measurement = matches!( gate.gate_type, pecos_quantum::GateType::MZ | pecos_quantum::GateType::MeasureFree ); - let is_prep = matches!( - gate.gate_type, - pecos_quantum::GateType::PZ | pecos_quantum::GateType::QAlloc - ); - if is_measurement { - map.insert((node, true), loc_idx); - loc_idx += 1; - } else if is_prep { - map.insert((node, false), loc_idx); - loc_idx += 1; - } else { - map.insert((node, true), loc_idx); - loc_idx += 1; - map.insert((node, false), loc_idx); + let before = is_measurement; + for q in &gate.qubits { + let qi = q.index(); + map.entry((node, before)).or_default().push((qi, loc_idx)); loc_idx += 1; } } @@ -573,50 +827,41 @@ impl<'a> InfluenceBuilder<'a> { map } - /// Record influence of a fault at a location on a target (detector or logical). + /// Record per-qubit influence of a fault at a gate location. fn record_influence( prop: &PauliProp, - loc_idx: usize, + qubit_locs: &[(usize, usize)], // [(qubit, loc_idx), ...] target_idx: usize, is_detector: bool, recorder: &mut CompoundRecorder, ) { - // Check each Pauli type - for pauli in [Pauli::X, Pauli::Y, Pauli::Z] { - if Self::fault_anticommutes(prop, pauli) { - if is_detector { - #[allow(clippy::cast_possible_truncation)] // index fits in u32 - recorder.record_detector(loc_idx, pauli, target_idx as u32); - } else { - #[allow(clippy::cast_possible_truncation)] // index fits in u32 - recorder.record_logical(loc_idx, pauli, target_idx as u32); + for &(qubit, loc_idx) in qubit_locs { + for pauli in [Pauli::X, Pauli::Y, Pauli::Z] { + if Self::fault_anticommutes_qubit(prop, qubit, pauli) { + if is_detector { + #[allow(clippy::cast_possible_truncation)] + recorder.record_detector(loc_idx, pauli, target_idx as u32); + } else { + #[allow(clippy::cast_possible_truncation)] + recorder.record_dem_output(loc_idx, pauli, target_idx as u32); + } } } } } - /// Check if a fault Pauli anticommutes with the propagated observable. - fn fault_anticommutes(prop: &PauliProp, fault: Pauli) -> bool { - let mut anticom_count = 0; + /// Check if a single-qubit fault Pauli anticommutes with the propagated + /// observable on a specific qubit. + fn fault_anticommutes_qubit(prop: &PauliProp, qubit: usize, fault: Pauli) -> bool { + let has_x = prop.contains_x(qubit); + let has_z = prop.contains_z(qubit); match fault { - Pauli::I => return false, - Pauli::X => { - // X fault anticommutes with Z component - anticom_count += prop.get_z_qubits().len(); - } - Pauli::Z => { - // Z fault anticommutes with X component - anticom_count += prop.get_x_qubits().len(); - } - Pauli::Y => { - // Y fault anticommutes with both X and Z - anticom_count += prop.get_x_qubits().len(); - anticom_count += prop.get_z_qubits().len(); - } + Pauli::I => false, + Pauli::X => has_z, // X anticommutes with Z + Pauli::Z => has_x, // Z anticommutes with X + Pauli::Y => has_x ^ has_z, // Y anticommutes with X or Z but not both } - - anticom_count % 2 == 1 } } @@ -640,34 +885,28 @@ struct DetectorDef { /// Recorder for compound detector propagation. struct CompoundRecorder { num_locations: usize, - #[allow(dead_code)] - num_detectors: usize, - #[allow(dead_code)] - num_logicals: usize, // Buckets for detector influences [loc_idx][pauli] -> Vec detector_x: Vec>, detector_y: Vec>, detector_z: Vec>, - // Buckets for logical influences - logical_x: Vec>, - logical_y: Vec>, - logical_z: Vec>, + // Buckets for DEM-output influences. + dem_output_x: Vec>, + dem_output_y: Vec>, + dem_output_z: Vec>, } impl CompoundRecorder { - fn new(num_locations: usize, num_detectors: usize, num_logicals: usize) -> Self { + fn new(num_locations: usize) -> Self { Self { num_locations, - num_detectors, - num_logicals, detector_x: vec![Vec::new(); num_locations], detector_y: vec![Vec::new(); num_locations], detector_z: vec![Vec::new(); num_locations], - logical_x: vec![Vec::new(); num_locations], - logical_y: vec![Vec::new(); num_locations], - logical_z: vec![Vec::new(); num_locations], + dem_output_x: vec![Vec::new(); num_locations], + dem_output_y: vec![Vec::new(); num_locations], + dem_output_z: vec![Vec::new(); num_locations], } } @@ -676,31 +915,38 @@ impl CompoundRecorder { return; } match pauli { - Pauli::X => self.detector_x[loc_idx].push(detector_idx), - Pauli::Y => self.detector_y[loc_idx].push(detector_idx), - Pauli::Z => self.detector_z[loc_idx].push(detector_idx), + Pauli::X => toggle_bucket(&mut self.detector_x[loc_idx], detector_idx), + Pauli::Y => toggle_bucket(&mut self.detector_y[loc_idx], detector_idx), + Pauli::Z => toggle_bucket(&mut self.detector_z[loc_idx], detector_idx), Pauli::I => {} } } - fn record_logical(&mut self, loc_idx: usize, pauli: Pauli, logical_idx: u32) { + fn record_dem_output(&mut self, loc_idx: usize, pauli: Pauli, dem_output_idx: u32) { if loc_idx >= self.num_locations { return; } match pauli { - Pauli::X => self.logical_x[loc_idx].push(logical_idx), - Pauli::Y => self.logical_y[loc_idx].push(logical_idx), - Pauli::Z => self.logical_z[loc_idx].push(logical_idx), + Pauli::X => toggle_bucket(&mut self.dem_output_x[loc_idx], dem_output_idx), + Pauli::Y => toggle_bucket(&mut self.dem_output_y[loc_idx], dem_output_idx), + Pauli::Z => toggle_bucket(&mut self.dem_output_z[loc_idx], dem_output_idx), Pauli::I => {} } } - fn into_soa(self) -> super::propagator::dag::InfluencesSoA { + fn into_soa(mut self) -> super::propagator::dag::InfluencesSoA { use super::propagator::dag::InfluencesSoA; let mut soa = InfluencesSoA::with_capacity(self.num_locations); for loc_idx in 0..self.num_locations { + self.detector_x[loc_idx].sort_unstable(); + self.detector_y[loc_idx].sort_unstable(); + self.detector_z[loc_idx].sort_unstable(); + self.dem_output_x[loc_idx].sort_unstable(); + self.dem_output_y[loc_idx].sort_unstable(); + self.dem_output_z[loc_idx].sort_unstable(); + // Add detector influences for &det in &self.detector_x[loc_idx] { soa.detectors_x.push(det); @@ -712,15 +958,15 @@ impl CompoundRecorder { soa.detectors_z.push(det); } - // Add logical influences - for &log in &self.logical_x[loc_idx] { - soa.logicals_x.push(log); + // Add DEM-output influences + for &dem_output in &self.dem_output_x[loc_idx] { + soa.dem_outputs_x.push(dem_output); } - for &log in &self.logical_y[loc_idx] { - soa.logicals_y.push(log); + for &dem_output in &self.dem_output_y[loc_idx] { + soa.dem_outputs_y.push(dem_output); } - for &log in &self.logical_z[loc_idx] { - soa.logicals_z.push(log); + for &dem_output in &self.dem_output_z[loc_idx] { + soa.dem_outputs_z.push(dem_output); } soa.finish_location(); @@ -730,9 +976,18 @@ impl CompoundRecorder { } } +fn toggle_bucket(bucket: &mut Vec, value: u32) { + if let Some(pos) = bucket.iter().position(|&existing| existing == value) { + bucket.remove(pos); + } else { + bucket.push(value); + } +} + #[cfg(test)] mod tests { use super::*; + use crate::fault_tolerance::propagator::DemOutputKind; use pecos_quantum::DagCircuit; #[test] @@ -801,17 +1056,217 @@ mod tests { } #[test] - fn test_with_logical() { + fn test_with_tracked_pauli() { let mut dag = DagCircuit::new(); dag.pz(&[2]); dag.cx(&[(0, 2)]); dag.mz(&[2]); - let builder = InfluenceBuilder::new(&dag).with_logical_z(vec![0]); // Track Z logical on qubit 0 + let builder = InfluenceBuilder::new(&dag).with_z(&[0]); // Track Z logical on qubit 0 let map = builder.build(); // Should track the logical - assert!(map.influences.max_logical_index().is_some()); + assert!(map.influences.max_dem_output_index().is_some()); + } + + #[test] + fn test_dem_output_metadata_accepts_pauli_string_and_normalizes_phase() { + use pecos_core::{Pauli, QuarterPhase}; + + let pauli = + PauliString::from_paulis_with_phase(QuarterPhase::MinusI, &[Pauli::X, Pauli::Z]); + let metadata = DemOutputMetadata::tracked_pauli(pauli).with_label("xz"); + + assert_eq!(metadata.kind, DemOutputKind::TrackedPauli); + assert_eq!(metadata.label.as_deref(), Some("xz")); + assert_eq!(metadata.pauli.phase(), QuarterPhase::PlusOne); + assert_eq!(metadata.pauli.to_sparse_str(), "+X0 Z1"); + } + + #[test] + fn test_circuit_annotation_dem_output_metadata_tracks_observables_and_tracked_paulis() { + use pecos_core::pauli::X; + + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.h(&[0]); + let meas = dag.mz(&[0]); + dag.observable_labeled("record_obs", &[meas[0]]); + dag.tracked_pauli_labeled("track_x", X(0)); + + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + // 1 observable (record_obs) + 1 tracked Pauli (track_x) = 2 DEM outputs + assert_eq!(map.num_dem_outputs(), 1, "1 observable"); + assert_eq!(map.num_tracked_paulis(), 1, "1 tracked Pauli"); + assert_eq!(map.dem_output_metadata.len(), 2); + + // Observable comes first (annotations are processed in order) + assert_eq!(map.dem_output_metadata[0].kind, DemOutputKind::Observable); + assert_eq!( + map.dem_output_metadata[0].label.as_deref(), + Some("record_obs") + ); + + // Tracked Pauli second + assert_eq!(map.dem_output_metadata[1].kind, DemOutputKind::TrackedPauli); + assert_eq!(map.dem_output_metadata[1].label.as_deref(), Some("track_x")); + assert_eq!(map.dem_output_metadata[1].pauli.to_sparse_str(), "+X0"); + } + + #[test] + fn test_observable_measurements_propagate_from_their_own_nodes() { + use pecos_quantum::GateType; + + let mut dag = DagCircuit::new(); + dag.pz(&[0, 1]); + let early = dag.mz(&[0]); + dag.h(&[0]); + let late = dag.mz(&[1]); + dag.observable_labeled("split_time_obs", &[early[0], late[0]]); + + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + assert_eq!(map.num_observables(), 1); + + let post_early_h = map + .locations + .iter() + .enumerate() + .find(|(_, loc)| { + loc.gate_type == GateType::H + && loc.qubits.first().is_some_and(|q| q.index() == 0) + && !loc.before + }) + .map(|(idx, _)| idx) + .expect("H fault location after the early measurement"); + + for pauli in [Pauli::X, Pauli::Y, Pauli::Z] { + assert!( + map.get_observable_indices(post_early_h, pauli.as_u8()) + .is_empty(), + "faults after an already-recorded measurement must not flip that record" + ); + } + } + + #[test] + fn test_split_time_observable_fault_before_early_measurement_flips() { + // Circuit: PZ(0), PZ(1), MZ(0) [early], H(1), MZ(1) [late] + // Observable = MZ(0) XOR MZ(1) + // + // A prep fault on qubit 0 (after PZ(0), before MZ(0)) should flip the + // observable via the early measurement term. + use pecos_quantum::GateType; + + let mut dag = DagCircuit::new(); + dag.pz(&[0, 1]); + let early = dag.mz(&[0]); + dag.h(&[1]); + let late = dag.mz(&[1]); + dag.observable_labeled("split_obs", &[early[0], late[0]]); + + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + assert_eq!(map.num_observables(), 1); + + // Prep fault on qubit 0 (PZ after-gate location) should flip the + // observable because it propagates through the early MZ(0). + let prep_q0 = map + .locations + .iter() + .enumerate() + .find(|(_, loc)| { + loc.gate_type == GateType::PZ + && loc.qubits.first().is_some_and(|q| q.index() == 0) + && !loc.before + }) + .map(|(idx, _)| idx) + .expect("PZ(0) fault location"); + + // X fault after PZ propagates through MZ as a bit flip + assert!( + !map.get_observable_indices(prep_q0, Pauli::X.as_u8()) + .is_empty(), + "X fault before early measurement should flip observable" + ); + } + + #[test] + fn test_split_time_observable_fault_between_measurements_flips_late_only() { + // Circuit: PZ(0), PZ(1), MZ(0) [early], H(1), MZ(1) [late] + // Observable = MZ(0) XOR MZ(1) + // + // An H fault on qubit 1 (between the two measurements) should flip the + // observable via the late measurement term only. + use pecos_quantum::GateType; + + let mut dag = DagCircuit::new(); + dag.pz(&[0, 1]); + let early = dag.mz(&[0]); + dag.h(&[1]); + let late = dag.mz(&[1]); + dag.observable_labeled("split_obs", &[early[0], late[0]]); + + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + assert_eq!(map.num_observables(), 1); + + // H(1) fault location — between the two measurements, on qubit 1 + let h_q1 = map + .locations + .iter() + .enumerate() + .find(|(_, loc)| { + loc.gate_type == GateType::H + && loc.qubits.first().is_some_and(|q| q.index() == 1) + && !loc.before + }) + .map(|(idx, _)| idx) + .expect("H(1) fault location"); + + // X fault after H(1) becomes Z before MZ(1), which does NOT flip MZ. + // Z fault after H(1) becomes X before MZ(1), which DOES flip MZ. + assert!( + !map.get_observable_indices(h_q1, Pauli::X.as_u8()) + .is_empty() + || !map + .get_observable_indices(h_q1, Pauli::Z.as_u8()) + .is_empty(), + "at least one Pauli fault between measurements should flip the late term" + ); + } + + #[test] + fn test_duplicate_observable_terms_cancel_in_influence_map() { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.h(&[0]); + let meas = dag.mz(&[0]); + dag.observable_labeled("duplicate_record_obs", &[meas[0], meas[0]]); + + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + assert_eq!(map.num_observables(), 1); + for loc_idx in 0..map.locations.len() { + for pauli in [Pauli::X, Pauli::Y, Pauli::Z] { + assert!( + map.get_observable_indices(loc_idx, pauli.as_u8()) + .is_empty(), + "observable record XOR should cancel duplicate measurement terms" + ); + } + } } } diff --git a/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs new file mode 100644 index 000000000..50d828465 --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/lookup_decoder.rs @@ -0,0 +1,514 @@ +// 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. + +//! Maximum likelihood lookup table decoder. +//! +//! Builds a decoder by enumerating fault combinations up to a given weight, +//! computing their probabilities from a noise model, and for each syndrome +//! pattern choosing the most likely observable outcome. +//! +//! # Example +//! +//! ``` +//! use pecos_qec::fault_tolerance::lookup_decoder::LookupDecoder; +//! use pecos_qec::fault_tolerance::dem_builder::NoiseConfig; +//! use pecos_qec::fault_tolerance::propagator::dag::DagFaultInfluenceMap; +//! +//! let map = DagFaultInfluenceMap::with_capacity(0); +//! let noise = NoiseConfig::uniform(0.001); +//! +//! let decoder = LookupDecoder::build(&map, &noise, 3); +//! let result = decoder.decode(&[]); +//! assert!(result.known_syndrome); +//! assert!(result.corrections.is_empty()); +//! ``` + +use super::dem_builder::NoiseConfig; +use super::propagator::dag::{DagFaultInfluenceMap, DagSpacetimeLocation, GateFaultLocation}; +use pecos_core::gate_type::GateType; +use std::collections::BTreeMap; + +/// Maximum likelihood lookup table decoder. +/// +/// Maps syndrome patterns (sets of fired detectors) to the most likely +/// observable correction (which observables to flip). +#[derive(Debug, Clone)] +pub struct LookupDecoder { + /// Syndrome -> most likely observable flip pattern. + table: BTreeMap, Vec>, + /// Standard observable `L` IDs in correction-vector order. + observable_ids: Vec, + /// Maximum fault weight enumerated. + max_weight: usize, + /// Total probability mass accounted for (weight 0 through `max_weight`). + accounted_probability: f64, +} + +/// Result of decoding a syndrome. +#[derive(Debug, Clone)] +pub struct DecoderResult { + /// Which observables should be flipped (ML correction). + pub corrections: Vec, + /// Whether this syndrome was seen during enumeration. + pub known_syndrome: bool, + /// Whether any detector fired (non-empty syndrome). + /// For detection codes (d=2), discard shots where this is true. + pub detected: bool, +} + +impl LookupDecoder { + /// Build a lookup decoder by enumerating faults up to the given weight. + /// + /// Uses the standard per-gate circuit noise model: each gate faults with + /// probability p, and each non-identity Pauli is equally likely (p/3 for + /// 1-qubit, p/15 for 2-qubit). Idle gates with T1/T2 use biased noise. + #[must_use] + pub fn build(map: &DagFaultInfluenceMap, noise: &NoiseConfig, max_weight: usize) -> Self { + let observable_ids = observable_ids(map); + let num_observables = observable_ids.len(); + + let loc_probs = compute_location_probs(&map.locations, noise); + let locs = map.gate_fault_locations(); + + // Pre-compute events and no-fault probabilities per gate location. + // + // For PZ/MZ gates, Z faults are physically no-ops. Their probability + // is absorbed into the no-fault probability so the total sums to 1.0. + // + // The combo probability uses a ratio approach: + // base_prob = product of no_fault(i) over all locations + // combo_prob = base_prob * product of (event_prob / no_fault) for participating locs + let mut loc_no_fault_probs: Vec = Vec::with_capacity(locs.len()); + let mut loc_events: Vec> = Vec::with_capacity(locs.len()); + + for loc in &locs { + let p = gate_location_prob(loc, &loc_probs, &map.locations); + + let events = loc.all_events(); + let num_physical_events = events.len(); + + // For idle gates with T1/T2, compute biased per-Pauli probabilities + let idle_pauli_probs = if loc.gate_type == GateType::Idle { + let duration = map + .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)) + } else { + None + }; + + let n_qubits = loc.num_qubits(); + let custom_weights = if idle_pauli_probs.is_some() { + None + } else if n_qubits == 1 { + noise.p1_weights.as_ref() + } else { + noise.p2_weights.as_ref() + }; + + let event_probs: Vec = if let Some(pp) = &idle_pauli_probs { + events + .iter() + .map(|event| { + let pauli = event + .pauli + .paulis() + .first() + .map_or(pecos_core::Pauli::I, |&(pa, _)| pa); + match pauli { + pecos_core::Pauli::X => pp.px, + pecos_core::Pauli::Y => pp.py, + pecos_core::Pauli::Z => pp.pz, + pecos_core::Pauli::I => 0.0, + } + }) + .collect() + } else if let Some(weights) = custom_weights { + events + .iter() + .map(|event| p * weights.weight_for(&event.pauli)) + .collect() + } else { + // Event count is a small integer; precision loss is not a concern. + #[allow(clippy::cast_precision_loss)] + let per_event = if num_physical_events > 0 { + p / num_physical_events as f64 + } else { + 0.0 + }; + vec![per_event; events.len()] + }; + + // No-fault = 1 - sum(event probs), absorbing filtered Paulis + let total_event_prob: f64 = event_probs.iter().sum(); + let no_fault = (1.0 - total_event_prob).max(0.0); + + let event_data: Vec = events + .into_iter() + .zip(event_probs) + .map(|(event, prob)| { + let ratio = if no_fault > 0.0 { + prob / no_fault + } else { + prob + }; + EventData { + prob: ratio, + detectors: event.detectors, + observable_ids: event + .dem_outputs + .iter() + .filter_map(|&idx| map.observable_id_for_internal_dem_output(idx)) + .collect(), + } + }) + .collect(); + + loc_no_fault_probs.push(no_fault); + loc_events.push(event_data); + } + + // Accumulate syndrome probabilities separately from per-observable + // correction weights. This keeps probability accounting well-defined + // even for detector-only maps with zero observables. + let mut syndrome_probabilities: BTreeMap, f64> = BTreeMap::new(); + + // Accumulate: syndrome -> per-observable (flip_prob, noflip_prob) + let mut syndrome_data: BTreeMap, Vec<(f64, f64)>> = BTreeMap::new(); + + // Base probability: all locations no-fault + let base_prob: f64 = loc_no_fault_probs.iter().product(); + + // Weight 0: no faults. Empty syndrome, no observable flips. + { + syndrome_probabilities.insert(Vec::new(), base_prob); + let entry = syndrome_data + .entry(Vec::new()) + .or_insert_with(|| vec![(0.0, 0.0); num_observables]); + for w in entry.iter_mut() { + w.1 += base_prob; // no flip + } + } + + // Weight 1..max_weight + // Start with base_prob (all no-fault). Each event replaces a location's + // no-fault factor with the event factor via the pre-computed ratio. + let combo_state = ComboState { + prob: base_prob, + detectors: Vec::new(), + observable_ids: Vec::new(), + }; + + for weight in 1..=max_weight { + enumerate_combos( + &loc_events, + weight, + 0, + &combo_state, + &observable_ids, + &mut syndrome_probabilities, + &mut syndrome_data, + ); + } + + let accounted_probability: f64 = syndrome_probabilities.values().sum(); + + // Build ML decision table + let table = syndrome_data + .into_iter() + .map(|(syndrome, weights)| { + let corrections: Vec = weights + .iter() + .map(|&(flip, noflip)| flip > noflip) + .collect(); + (syndrome, corrections) + }) + .collect(); + + Self { + table, + observable_ids, + max_weight, + accounted_probability, + } + } + + /// Decode a syndrome given as detector indices. + #[must_use] + pub fn decode(&self, syndrome: &[u32]) -> DecoderResult { + let mut key: Vec = syndrome.to_vec(); + key.sort_unstable(); + let detected = !key.is_empty(); + + if let Some(corrections) = self.table.get(&key) { + DecoderResult { + corrections: corrections.clone(), + known_syndrome: true, + detected, + } + } else { + DecoderResult { + corrections: vec![false; self.observable_ids.len()], + known_syndrome: false, + detected, + } + } + } + + /// Decode from a boolean detector vector. + #[must_use] + pub fn decode_from_bools(&self, detectors: &[bool]) -> DecoderResult { + let syndrome: Vec = detectors + .iter() + .enumerate() + .filter_map(|(i, &fired)| { + if fired { + #[allow(clippy::cast_possible_truncation)] + Some(i as u32) + } else { + None + } + }) + .collect(); + self.decode(&syndrome) + } + + /// Number of distinct syndrome patterns in the table. + #[must_use] + pub fn num_syndromes(&self) -> usize { + self.table.len() + } + + /// Maximum fault weight that was enumerated. + #[must_use] + pub fn max_weight(&self) -> usize { + self.max_weight + } + + /// Number of observable channels. + #[must_use] + pub fn num_observables(&self) -> usize { + self.observable_ids.len() + } + + /// Standard observable `L` IDs in correction-vector order. + #[must_use] + pub fn observable_ids(&self) -> &[u32] { + &self.observable_ids + } + + /// Estimated upper bound on the probability mass NOT accounted for + /// due to weight truncation. + /// + /// This bounds the total probability of fault combinations with + /// weight > `max_weight`. For low noise rates, this is small. + /// If it's large (> 0.01), consider increasing `max_weight`. + #[must_use] + pub fn truncation_bound(&self) -> f64 { + (1.0 - self.accounted_probability).max(0.0) + } + + /// Total probability mass accounted for (weights 0 through `max_weight`). + /// + /// Should be close to 1.0 for low noise rates with sufficient `max_weight`. + #[must_use] + pub fn accounted_probability(&self) -> f64 { + self.accounted_probability + } +} + +// ============================================================================ +// Internal types and helpers +// ============================================================================ + +struct EventData { + prob: f64, + detectors: Vec, + observable_ids: Vec, +} + +#[derive(Clone)] +struct ComboState { + prob: f64, + detectors: Vec, + observable_ids: Vec, +} + +impl ComboState { + /// Compose with a new event: XOR detectors/observable IDs, multiply prob. + fn compose(&self, event: &EventData) -> Self { + let mut detectors = self.detectors.clone(); + xor_into(&mut detectors, &event.detectors); + let mut observable_ids = self.observable_ids.clone(); + xor_into(&mut observable_ids, &event.observable_ids); + Self { + prob: self.prob * event.prob, + detectors, + observable_ids, + } + } +} + +/// Symmetric difference (XOR) of sorted u32 vecs. +fn xor_into(acc: &mut Vec, other: &[u32]) { + if other.is_empty() { + return; + } + if acc.is_empty() { + acc.extend_from_slice(other); + return; + } + let mut result = Vec::with_capacity(acc.len() + other.len()); + let (mut i, mut j) = (0, 0); + while i < acc.len() && j < other.len() { + match acc[i].cmp(&other[j]) { + std::cmp::Ordering::Less => { + result.push(acc[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + result.push(other[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + i += 1; + j += 1; + } + } + } + result.extend_from_slice(&acc[i..]); + result.extend_from_slice(&other[j..]); + *acc = result; +} + +/// Recursive combination enumeration with probability tracking. +fn enumerate_combos( + loc_events: &[Vec], + remaining: usize, + start_loc: usize, + state: &ComboState, + observable_ids: &[u32], + syndrome_probabilities: &mut BTreeMap, f64>, + syndrome_data: &mut BTreeMap, Vec<(f64, f64)>>, +) { + if remaining == 0 { + let mut syndrome = state.detectors.clone(); + syndrome.sort_unstable(); + + *syndrome_probabilities + .entry(syndrome.clone()) + .or_insert(0.0) += state.prob; + + let entry = syndrome_data + .entry(syndrome) + .or_insert_with(|| vec![(0.0, 0.0); observable_ids.len()]); + + for (&observable_id, weights) in observable_ids.iter().zip(entry.iter_mut()) { + let flipped = state.observable_ids.contains(&observable_id); + if flipped { + weights.0 += state.prob; + } else { + weights.1 += state.prob; + } + } + return; + } + + for loc_idx in start_loc..loc_events.len() { + for event in &loc_events[loc_idx] { + let next_state = state.compose(event); + enumerate_combos( + loc_events, + remaining - 1, + loc_idx + 1, + &next_state, + observable_ids, + syndrome_probabilities, + syndrome_data, + ); + } + } +} + +/// Compute per-location error probabilities from noise config. +fn compute_location_probs(locations: &[DagSpacetimeLocation], noise: &NoiseConfig) -> Vec { + super::dem_builder::sampler::compute_location_probs_from_noise(locations, noise) +} + +/// Get the per-qubit error probability for a gate fault location. +/// +/// Since `gate_fault_locations` groups per-qubit locations, all per-qubit +/// locations within a gate have the same gate-type-based probability. +fn gate_location_prob( + loc: &GateFaultLocation<'_>, + loc_probs: &[f64], + all_locations: &[DagSpacetimeLocation], +) -> f64 { + // Find any per-qubit location in the influence map for this gate + for (i, l) in all_locations.iter().enumerate() { + if l.node == loc.node && l.before == loc.before { + return loc_probs[i]; + } + } + 0.0 +} + +fn observable_ids(map: &DagFaultInfluenceMap) -> Vec { + map.observable_ids().into_iter().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fault_tolerance::InfluenceBuilder; + use pecos_core::pauli::X; + use pecos_quantum::DagCircuit; + + #[test] + fn observable_indices_use_compact_l_namespace_with_tracked_paulis() { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.tracked_pauli_labeled("track_x", X(0)); + let meas = dag.mz(&[0]); + dag.observable_labeled("obs0", &[meas[0]]); + + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + assert_eq!(map.num_tracked_paulis(), 1); + assert_eq!(map.num_observables(), 1); + + let decoder = LookupDecoder::build(&map, &NoiseConfig::uniform(0.01), 1); + assert_eq!(decoder.observable_ids(), &[0]); + } + + #[test] + fn detector_only_decoder_accounts_probability_without_observables() { + let mut dag = DagCircuit::new(); + dag.pz(&[0, 1]); + dag.cx(&[(0, 1)]); + let meas = dag.mz(&[1]); + dag.detector(&[meas[0]]); + + let map = InfluenceBuilder::new(&dag).build(); + assert_eq!(map.num_observables(), 0); + + let decoder = LookupDecoder::build(&map, &NoiseConfig::uniform(0.01), 0); + assert_eq!(decoder.num_observables(), 0); + assert!(decoder.accounted_probability() > 0.0); + assert!(decoder.truncation_bound() < 1.0); + } +} diff --git a/crates/pecos-qec/src/fault_tolerance/noisy_sampler.rs b/crates/pecos-qec/src/fault_tolerance/noisy_sampler.rs deleted file mode 100644 index 7dca682dd..000000000 --- a/crates/pecos-qec/src/fault_tolerance/noisy_sampler.rs +++ /dev/null @@ -1,692 +0,0 @@ -//! Noisy Measurement Sampler using Precomputed Influence Maps -//! -//! This module provides efficient sampling of noisy measurement outcomes using -//! backward-propagated influence maps. Instead of simulating the full circuit -//! for each shot, we: -//! -//! 1. Precompute which fault locations affect which detectors/logicals -//! 2. For each shot, sample which faults fire -//! 3. Use O(1) lookups to find which detectors/logicals flip -//! -//! This approach is O(shots × `fault_locations`) instead of O(shots × `circuit_depth`), -//! providing significant speedup for noisy QEC simulations. -//! -//! # Example -//! -//! ``` -//! use pecos_qec::fault_tolerance::noisy_sampler::{NoisySampler, UniformNoiseModel}; -//! use pecos_qec::fault_tolerance::DagFaultAnalyzer; -//! use pecos_quantum::DagCircuit; -//! -//! // Build a simple circuit -//! let mut dag = DagCircuit::new(); -//! dag.pz(&[2]); -//! dag.cx(&[(0, 2)]); -//! dag.cx(&[(1, 2)]); -//! dag.mz(&[2]); -//! -//! // Build influence map (precomputation) -//! let analyzer = DagFaultAnalyzer::new(&dag); -//! let influence_map = analyzer.build_influence_map(); -//! -//! // Create noise model (uniform depolarizing) -//! let noise = UniformNoiseModel::depolarizing(0.001); -//! -//! // Sample many shots -//! let mut sampler = NoisySampler::new(&influence_map, noise, 42); -//! let results = sampler.sample(100); -//! -//! // Analyze results -//! for shot in &results { -//! if shot.has_logical_error() { -//! // ... -//! } -//! } -//! ``` - -use super::propagator::Pauli; -use super::propagator::dag::DagFaultInfluenceMap; -use pecos_random::rng_ext::RngProbabilityExt; -use pecos_random::{PecosRng, Rng}; -use std::collections::BTreeSet; - -/// Result from a single shot of noisy sampling. -#[derive(Debug, Clone)] -pub struct ShotResult { - /// Detectors that flipped (indices into `influence_map.detectors`). - pub detector_flips: Vec, - /// Logicals that flipped (indices). - pub logical_flips: Vec, - /// Number of faults that fired in this shot. - pub fault_count: usize, -} - -impl ShotResult { - /// Create an empty result. - #[must_use] - pub fn new() -> Self { - Self { - detector_flips: Vec::new(), - logical_flips: Vec::new(), - fault_count: 0, - } - } - - /// Check if any logical error occurred. - #[inline] - #[must_use] - pub fn has_logical_error(&self) -> bool { - !self.logical_flips.is_empty() - } - - /// Check if any syndrome was triggered. - #[inline] - #[must_use] - pub fn has_syndrome(&self) -> bool { - !self.detector_flips.is_empty() - } - - /// Check if this is an undetectable logical error. - #[inline] - #[must_use] - pub fn is_undetectable_logical_error(&self) -> bool { - self.has_logical_error() && !self.has_syndrome() - } -} - -impl Default for ShotResult { - fn default() -> Self { - Self::new() - } -} - -/// Trait for noise models that can sample faults at each location. -pub trait NoiseModel { - /// Sample a Pauli fault at the given location. - /// - /// Returns `Pauli::I` for no fault, or `X/Y/Z` for a fault. - fn sample_fault(&mut self, loc_idx: usize, rng: &mut impl Rng) -> Pauli; - - /// Get the total error probability at a location (for statistics). - fn error_probability(&self, loc_idx: usize) -> f64; -} - -/// Uniform depolarizing noise model. -/// -/// Same error probability at every location. With probability p, -/// applies X, Y, or Z with equal probability (p/3 each). -#[derive(Debug, Clone)] -pub struct UniformNoiseModel { - /// Total error probability per location. - p_error: f64, - /// Threshold for error occurrence (`p_error` * `u64::MAX`). - threshold: u64, -} - -impl UniformNoiseModel { - /// Create a uniform noise model with the given error probability. - #[must_use] - pub fn new(p_error: f64) -> Self { - #[allow( - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - clippy::cast_precision_loss - )] - // probability in [0,1] so product fits in u64 - let threshold = (p_error * u64::MAX as f64) as u64; - Self { p_error, threshold } - } - - /// Create a depolarizing noise model (convenience alias). - #[must_use] - pub fn depolarizing(p_error: f64) -> Self { - Self::new(p_error) - } -} - -impl NoiseModel for UniformNoiseModel { - fn sample_fault(&mut self, _loc_idx: usize, rng: &mut impl Rng) -> Pauli { - let rand = rng.next_u64(); - if rand < self.threshold { - // Error occurred, sample which Pauli - match (rng.next_u32() % 3) as u8 { - 0 => Pauli::X, - 1 => Pauli::Y, - _ => Pauli::Z, - } - } else { - Pauli::I - } - } - - fn error_probability(&self, _loc_idx: usize) -> f64 { - self.p_error - } -} - -/// Per-location noise model with different probabilities. -/// -/// Each location can have different error rates. -#[derive(Debug, Clone)] -pub struct PerLocationNoiseModel { - /// Error probabilities per location. - probabilities: Vec, - /// Precomputed thresholds. - thresholds: Vec, -} - -impl PerLocationNoiseModel { - /// Create from a vector of error probabilities (one per location). - #[must_use] - pub fn new(probabilities: Vec) -> Self { - let thresholds = probabilities - .iter() - .map(|&p| { - #[allow( - clippy::cast_sign_loss, - clippy::cast_possible_truncation, - clippy::cast_precision_loss - )] - // probability in [0,1] so product fits in u64 - { - (p * u64::MAX as f64) as u64 - } - }) - .collect(); - Self { - probabilities, - thresholds, - } - } -} - -impl NoiseModel for PerLocationNoiseModel { - fn sample_fault(&mut self, loc_idx: usize, rng: &mut impl Rng) -> Pauli { - let threshold = self.thresholds.get(loc_idx).copied().unwrap_or(0); - let rand = rng.next_u64(); - if rand < threshold { - match (rng.next_u32() % 3) as u8 { - 0 => Pauli::X, - 1 => Pauli::Y, - _ => Pauli::Z, - } - } else { - Pauli::I - } - } - - fn error_probability(&self, loc_idx: usize) -> f64 { - self.probabilities.get(loc_idx).copied().unwrap_or(0.0) - } -} - -/// Noisy measurement sampler using precomputed influence maps. -/// -/// This provides efficient sampling by using O(1) lookups from the -/// influence map instead of full circuit simulation. -pub struct NoisySampler<'a, N: NoiseModel> { - /// Reference to the precomputed influence map. - influence_map: &'a DagFaultInfluenceMap, - /// Noise model for sampling faults. - noise_model: N, - /// Random number generator. - rng: PecosRng, - /// Number of fault locations. - num_locations: usize, - /// Number of detectors. - num_detectors: usize, - /// Number of logicals (derived from influence map). - num_logicals: usize, -} - -impl<'a, N: NoiseModel> NoisySampler<'a, N> { - /// Create a new noisy sampler. - /// - /// # Arguments - /// * `influence_map` - Precomputed influence map from backward propagation - /// * `noise_model` - Noise model for sampling faults - /// * `seed` - RNG seed for reproducibility - pub fn new(influence_map: &'a DagFaultInfluenceMap, noise_model: N, seed: u64) -> Self { - let num_locations = influence_map.locations.len(); - let num_detectors = influence_map.detectors.len(); - - // Estimate num_logicals from the influence map - // (could be stored explicitly in the map) - let num_logicals = influence_map - .influences - .max_logical_index() - .map_or(0, |i| i + 1); - - Self { - influence_map, - noise_model, - rng: PecosRng::seed_from_u64(seed), - num_locations, - num_detectors, - num_logicals, - } - } - - /// Sample a single shot. - pub fn sample_one(&mut self) -> ShotResult { - // Track which detectors/logicals have flipped (using XOR) - let mut detector_flip_counts: Vec = vec![0; self.num_detectors]; - let mut logical_flip_counts: Vec = vec![0; self.num_logicals.max(1)]; - let mut fault_count = 0; - - // Sample each fault location - for loc_idx in 0..self.num_locations { - let pauli = self.noise_model.sample_fault(loc_idx, &mut self.rng); - - if pauli != Pauli::I { - fault_count += 1; - - // Get affected detectors (O(1) lookup) - let detectors = self - .influence_map - .get_detector_indices(loc_idx, pauli.as_u8()); - for &det_idx in detectors { - detector_flip_counts[det_idx as usize] ^= 1; - } - - // Get affected logicals (O(1) lookup) - let logicals = self - .influence_map - .get_logical_indices(loc_idx, pauli.as_u8()); - for &log_idx in logicals { - if (log_idx as usize) < logical_flip_counts.len() { - logical_flip_counts[log_idx as usize] ^= 1; - } - } - } - } - - // Collect indices that ended up flipped (odd count) - let detector_flips: Vec = detector_flip_counts - .iter() - .enumerate() - .filter(|(_, c)| **c == 1) - .map(|(i, _)| { - #[allow(clippy::cast_possible_truncation)] // detector index fits in u32 - { - i as u32 - } - }) - .collect(); - - let logical_flips: Vec = logical_flip_counts - .iter() - .enumerate() - .filter(|(_, c)| **c == 1) - .map(|(i, _)| { - #[allow(clippy::cast_possible_truncation)] // logical index fits in u32 - { - i as u32 - } - }) - .collect(); - - ShotResult { - detector_flips, - logical_flips, - fault_count, - } - } - - /// Sample multiple shots. - pub fn sample(&mut self, num_shots: usize) -> Vec { - (0..num_shots).map(|_| self.sample_one()).collect() - } - - /// Sample and compute statistics. - pub fn sample_statistics(&mut self, num_shots: usize) -> SamplingStatistics { - let mut stats = SamplingStatistics::new(); - - for _ in 0..num_shots { - let result = self.sample_one(); - stats.record(&result); - } - - stats - } - - /// Get the number of fault locations. - pub fn num_locations(&self) -> usize { - self.num_locations - } - - /// Get the number of detectors. - pub fn num_detectors(&self) -> usize { - self.num_detectors - } -} - -/// Statistics from sampling. -#[derive(Debug, Clone)] -pub struct SamplingStatistics { - /// Total number of shots. - pub total_shots: usize, - /// Number of shots with logical errors. - pub logical_error_count: usize, - /// Number of shots with syndromes (detector flips). - pub syndrome_count: usize, - /// Number of undetectable logical errors. - pub undetectable_count: usize, - /// Total faults across all shots. - pub total_faults: usize, -} - -impl SamplingStatistics { - /// Create empty statistics. - #[must_use] - pub fn new() -> Self { - Self { - total_shots: 0, - logical_error_count: 0, - syndrome_count: 0, - undetectable_count: 0, - total_faults: 0, - } - } - - /// Record a shot result. - pub fn record(&mut self, result: &ShotResult) { - self.total_shots += 1; - self.total_faults += result.fault_count; - - if result.has_logical_error() { - self.logical_error_count += 1; - } - if result.has_syndrome() { - self.syndrome_count += 1; - } - if result.is_undetectable_logical_error() { - self.undetectable_count += 1; - } - } - - /// Logical error rate. - #[must_use] - #[allow(clippy::cast_precision_loss)] // rate calculation - pub fn logical_error_rate(&self) -> f64 { - if self.total_shots == 0 { - 0.0 - } else { - self.logical_error_count as f64 / self.total_shots as f64 - } - } - - /// Syndrome rate (fraction of shots with non-trivial syndrome). - #[must_use] - #[allow(clippy::cast_precision_loss)] // rate calculation - pub fn syndrome_rate(&self) -> f64 { - if self.total_shots == 0 { - 0.0 - } else { - self.syndrome_count as f64 / self.total_shots as f64 - } - } - - /// Undetectable error rate. - #[must_use] - #[allow(clippy::cast_precision_loss)] // rate calculation - pub fn undetectable_rate(&self) -> f64 { - if self.total_shots == 0 { - 0.0 - } else { - self.undetectable_count as f64 / self.total_shots as f64 - } - } - - /// Average faults per shot. - #[must_use] - #[allow(clippy::cast_precision_loss)] // rate calculation - pub fn average_faults(&self) -> f64 { - if self.total_shots == 0 { - 0.0 - } else { - self.total_faults as f64 / self.total_shots as f64 - } - } -} - -impl Default for SamplingStatistics { - fn default() -> Self { - Self::new() - } -} - -// ============================================================================ -// Optimized Sampler using PecosRng batching and sparse tracking -// ============================================================================ - -/// Optimized noisy sampler using `PecosRng` batching and sparse flip tracking. -/// -/// Key optimizations over [`NoisySampler`]: -/// 1. Uses `check_probability_indices()` to get sparse list of fault locations -/// 2. Uses `BTreeSet` for flip tracking instead of dense `Vec` -/// 3. Reuses buffers across shots to avoid allocation overhead -/// -/// For low error rates (p < 0.01), this can be 2-3x faster than the standard sampler. -pub struct FastNoisySampler<'a> { - /// Reference to the precomputed influence map. - influence_map: &'a DagFaultInfluenceMap, - /// Error probability. - p_error: f64, - /// Precomputed threshold for probability check. - threshold: u64, - /// Random number generator (`PecosRng` for batching). - rng: PecosRng, - /// Number of fault locations. - num_locations: usize, - /// Number of logicals. - num_logicals: usize, - /// Reusable buffer for detector flips (sparse). - detector_flips_buffer: BTreeSet, - /// Reusable buffer for logical flips (sparse). - logical_flips_buffer: BTreeSet, -} - -impl<'a> FastNoisySampler<'a> { - /// Create a new optimized sampler. - /// - /// # Arguments - /// * `influence_map` - Precomputed influence map - /// * `p_error` - Uniform depolarizing error probability - /// * `seed` - RNG seed - #[must_use] - pub fn new(influence_map: &'a DagFaultInfluenceMap, p_error: f64, seed: u64) -> Self { - let num_locations = influence_map.locations.len(); - let num_logicals = influence_map - .influences - .max_logical_index() - .map_or(0, |i| i + 1); - - let rng = PecosRng::seed_from_u64(seed); - let threshold = rng.probability_threshold(p_error); - - Self { - influence_map, - p_error, - threshold, - rng, - num_locations, - num_logicals, - detector_flips_buffer: BTreeSet::new(), - logical_flips_buffer: BTreeSet::new(), - } - } - - /// Sample a single shot using sparse tracking. - pub fn sample_one(&mut self) -> ShotResult { - // Clear reusable buffers - self.detector_flips_buffer.clear(); - self.logical_flips_buffer.clear(); - - // Get sparse list of fault locations using batched RNG - let fault_indices = self - .rng - .check_probability_indices(self.threshold, self.num_locations); - - let fault_count = fault_indices.len(); - - // Process only the faulted locations - for loc_idx in fault_indices { - // Select Pauli type: 0=X, 1=Y, 2=Z - let pauli_idx = self.rng.random_index_3(); - let pauli = match pauli_idx { - 0 => Pauli::X, - 1 => Pauli::Y, - _ => Pauli::Z, - }; - - // Toggle affected detectors (XOR via HashSet toggle) - let detectors = self - .influence_map - .get_detector_indices(loc_idx, pauli.as_u8()); - for &det_idx in detectors { - if !self.detector_flips_buffer.remove(&det_idx) { - self.detector_flips_buffer.insert(det_idx); - } - } - - // Toggle affected logicals - let logicals = self - .influence_map - .get_logical_indices(loc_idx, pauli.as_u8()); - for &log_idx in logicals { - if (log_idx as usize) < self.num_logicals - && !self.logical_flips_buffer.remove(&log_idx) - { - self.logical_flips_buffer.insert(log_idx); - } - } - } - - // Collect results - let detector_flips: Vec = self.detector_flips_buffer.iter().copied().collect(); - let logical_flips: Vec = self.logical_flips_buffer.iter().copied().collect(); - - ShotResult { - detector_flips, - logical_flips, - fault_count, - } - } - - /// Sample multiple shots. - pub fn sample(&mut self, num_shots: usize) -> Vec { - (0..num_shots).map(|_| self.sample_one()).collect() - } - - /// Sample and compute statistics. - pub fn sample_statistics(&mut self, num_shots: usize) -> SamplingStatistics { - let mut stats = SamplingStatistics::new(); - - for _ in 0..num_shots { - let result = self.sample_one(); - stats.record(&result); - } - - stats - } - - /// Get the error probability. - #[must_use] - pub fn p_error(&self) -> f64 { - self.p_error - } - - /// Get the number of fault locations. - #[must_use] - pub fn num_locations(&self) -> usize { - self.num_locations - } -} - -#[cfg(test)] -mod tests { - use super::*; - - // Mock influence map for testing - #[allow(dead_code)] - fn create_test_influence_map() -> DagFaultInfluenceMap { - DagFaultInfluenceMap::with_capacity(0) - } - - #[test] - fn test_uniform_noise_model() { - let mut noise = UniformNoiseModel::new(0.5); - let mut rng = PecosRng::seed_from_u64(42); - - let mut error_count = 0; - for _ in 0..1000 { - if noise.sample_fault(0, &mut rng) != Pauli::I { - error_count += 1; - } - } - - // Should be roughly 50% - assert!(error_count > 400 && error_count < 600); - } - - #[test] - fn test_per_location_noise_model() { - let probs = vec![0.0, 0.5, 1.0]; - let mut noise = PerLocationNoiseModel::new(probs); - let mut rng = PecosRng::seed_from_u64(42); - - // Location 0: never errors - for _ in 0..100 { - assert_eq!(noise.sample_fault(0, &mut rng), Pauli::I); - } - - // Location 2: always errors - for _ in 0..100 { - assert_ne!(noise.sample_fault(2, &mut rng), Pauli::I); - } - } - - #[test] - fn test_shot_result() { - let mut result = ShotResult::new(); - assert!(!result.has_logical_error()); - assert!(!result.has_syndrome()); - - result.logical_flips.push(0); - assert!(result.has_logical_error()); - assert!(result.is_undetectable_logical_error()); - - result.detector_flips.push(0); - assert!(!result.is_undetectable_logical_error()); - } - - #[test] - fn test_statistics() { - let mut stats = SamplingStatistics::new(); - - // Shot with no errors - stats.record(&ShotResult::new()); - - // Shot with syndrome only - let mut shot_with_syndrome = ShotResult::new(); - shot_with_syndrome.detector_flips.push(0); - stats.record(&shot_with_syndrome); - - // Shot with logical error - let mut shot_with_logical = ShotResult::new(); - shot_with_logical.logical_flips.push(0); - shot_with_logical.detector_flips.push(1); - stats.record(&shot_with_logical); - - // Shot with undetectable logical - let mut shot_undetectable = ShotResult::new(); - shot_undetectable.logical_flips.push(0); - stats.record(&shot_undetectable); - - assert_eq!(stats.total_shots, 4); - assert_eq!(stats.logical_error_count, 2); - assert_eq!(stats.syndrome_count, 2); - assert_eq!(stats.undetectable_count, 1); - } -} diff --git a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs index 7dcedca99..b7cacb7c3 100644 --- a/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs +++ b/crates/pecos-qec/src/fault_tolerance/pauli_prop_checker.rs @@ -76,7 +76,7 @@ pub fn detect_input_qubits(circuit: &TickCircuit) -> Vec { let mut prepared_qubits: HashSet = HashSet::new(); for (_tick_idx, tick) in circuit.iter_ticks() { - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { for &qubit in &gate.qubits { let q = qubit.index(); all_qubits.insert(q); @@ -108,7 +108,7 @@ pub fn detect_ancilla_qubits(circuit: &TickCircuit) -> Vec { let mut prepared_qubits: HashSet = HashSet::new(); for (_tick_idx, tick) in circuit.iter_ticks() { - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { if gate.gate_type == GateType::PZ { for &qubit in &gate.qubits { prepared_qubits.insert(qubit.index()); @@ -142,7 +142,7 @@ pub fn detect_output_qubits(circuit: &TickCircuit) -> Vec { let mut measured_qubits: HashSet = HashSet::new(); for (_tick_idx, tick) in circuit.iter_ticks() { - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { for &qubit in &gate.qubits { let q = qubit.index(); all_qubits.insert(q); @@ -191,7 +191,7 @@ impl CircuitIO { let mut measured_qubits: HashSet = HashSet::new(); for (_tick_idx, tick) in circuit.iter_ticks() { - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { for &qubit in &gate.qubits { let q = qubit.index(); all_qubits.insert(q); @@ -296,8 +296,8 @@ pub fn propagate_fault(circuit: &TickCircuit, fault: &PauliFault) -> PauliProp { } // Apply all gates in this tick - for gate in tick.gates() { - apply_gate(&mut prop, gate, Direction::Forward); + for gate in tick.iter_gate_batches() { + apply_gate(&mut prop, gate.as_gate(), Direction::Forward); } } @@ -335,8 +335,8 @@ pub fn propagate_faults(circuit: &TickCircuit, faults: &FaultConfiguration) -> P // Propagate through the circuit from the minimum tick onward for (tick_idx, tick) in circuit.iter_ticks() { if tick_idx >= min_tick { - for gate in tick.gates() { - apply_gate(&mut prop, gate, Direction::Forward); + for gate in tick.iter_gate_batches() { + apply_gate(&mut prop, gate.as_gate(), Direction::Forward); } } } @@ -1011,7 +1011,7 @@ pub fn extract_measurement_rounds(circuit: &TickCircuit) -> Vec { // Z-basis measurement @@ -1070,7 +1070,7 @@ fn propagate_until_tick(circuit: &TickCircuit, fault: &PauliFault, until_tick: u } // Propagate through all gates in this tick - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { let qubits: Vec = gate.qubits.iter().copied().collect(); match gate.gate_type { GateType::CX if qubits.len() >= 2 => { @@ -1083,28 +1083,53 @@ fn propagate_until_tick(circuit: &TickCircuit, fault: &PauliFault, until_tick: u prop.cy(&[(qubits[0], qubits[1])]); } GateType::H => { - for q in &qubits { - prop.h(&[*q]); - } + prop.h(&qubits); } - GateType::SZ | GateType::SZdg => { - for q in &qubits { - prop.sz(&[*q]); - } + GateType::F => { + prop.f(&qubits); } - GateType::SX | GateType::SXdg => { - for q in &qubits { - prop.sx(&[*q]); - } + GateType::Fdg => { + prop.fdg(&qubits); } - GateType::SY | GateType::SYdg => { - for q in &qubits { - prop.sy(&[*q]); - } + GateType::SX => { + prop.sx(&qubits); + } + GateType::SXdg => { + prop.sxdg(&qubits); + } + GateType::SY => { + prop.sy(&qubits); + } + GateType::SYdg => { + prop.sydg(&qubits); + } + GateType::SZ => { + prop.sz(&qubits); + } + GateType::SZdg => { + prop.szdg(&qubits); } GateType::SWAP if qubits.len() >= 2 => { prop.swap(&[(qubits[0], qubits[1])]); } + GateType::SXX if qubits.len() >= 2 => { + prop.sxx(&[(qubits[0], qubits[1])]); + } + GateType::SXXdg if qubits.len() >= 2 => { + prop.sxxdg(&[(qubits[0], qubits[1])]); + } + GateType::SYY if qubits.len() >= 2 => { + prop.syy(&[(qubits[0], qubits[1])]); + } + GateType::SYYdg if qubits.len() >= 2 => { + prop.syydg(&[(qubits[0], qubits[1])]); + } + GateType::SZZ if qubits.len() >= 2 => { + prop.szz(&[(qubits[0], qubits[1])]); + } + GateType::SZZdg if qubits.len() >= 2 => { + prop.szzdg(&[(qubits[0], qubits[1])]); + } _ => {} } } @@ -2099,8 +2124,8 @@ impl<'a> PauliPropChecker<'a> { // Propagate through circuit for (_tick_idx, tick) in self.circuit.iter_ticks() { - for gate in tick.gates() { - apply_gate(&mut prop, gate, Direction::Forward); + for gate in tick.iter_gate_batches() { + apply_gate(&mut prop, gate.as_gate(), Direction::Forward); } } @@ -3576,7 +3601,8 @@ mod tests { let mut circuit = TickCircuit::new(); circuit.tick().pz(&[0, 1, 2]); // Prepare all qubits circuit.tick().h(&[0]); - circuit.tick().cx(&[(0, 1), (0, 2)]); + circuit.tick().cx(&[(0, 1)]); + circuit.tick().cx(&[(0, 2)]); let input_qubits = detect_input_qubits(&circuit); assert!( @@ -4073,7 +4099,8 @@ mod tests { let mut circuit = TickCircuit::new(); circuit.tick().pz(&[0, 1, 2]); // All qubits prepared circuit.tick().h(&[0]); - circuit.tick().cx(&[(0, 1), (0, 2)]); + circuit.tick().cx(&[(0, 1)]); + circuit.tick().cx(&[(0, 2)]); circuit.tick().mz(&[0, 1, 2]); // All measured let checker = PauliPropChecker::new(&circuit); @@ -4097,7 +4124,8 @@ mod tests { let mut circuit = TickCircuit::new(); circuit.tick().pz(&[0, 1, 2]); // All qubits prepared circuit.tick().h(&[0]); - circuit.tick().cx(&[(0, 1), (0, 2)]); + circuit.tick().cx(&[(0, 1)]); + circuit.tick().cx(&[(0, 2)]); // No measurement - outputs go to next stage let checker = PauliPropChecker::new(&circuit); @@ -4169,7 +4197,8 @@ mod tests { // Use a simple circuit where we know failures will occur let mut circuit = TickCircuit::new(); circuit.tick().pz(&[2]); // Ancilla - circuit.tick().cx(&[(0, 2), (1, 2)]); + circuit.tick().cx(&[(0, 2)]); + circuit.tick().cx(&[(1, 2)]); circuit.tick().mz(&[2]); let config = FaultCheckConfig::new().with_weight(1).all_paulis(); diff --git a/crates/pecos-qec/src/fault_tolerance/propagator.rs b/crates/pecos-qec/src/fault_tolerance/propagator.rs index dfed6934b..e35bb7239 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator.rs @@ -13,9 +13,10 @@ //! Pauli propagation infrastructure and fault analysis. //! //! This module provides bidirectional Pauli propagation through quantum circuits, -//! with specialized support for fault tolerance analysis. By propagating observables -//! backward from measurements/logicals, we can efficiently determine which faults -//! affect which detectors: +//! with specialized support for fault tolerance analysis. By propagating +//! detector/observable measurement parities and unmeasured tracked Pauli +//! operators backward through the circuit, we can efficiently determine which +//! faults affect which outputs: //! //! 1. **Speed up fault enumeration** - O(1) lookup instead of `O(circuit_depth)` propagation //! 2. **Build detector error models** - Direct mapping from faults to detectors @@ -43,10 +44,17 @@ //! let analyzer = DagFaultAnalyzer::new(&dag); //! let map = analyzer.build_influence_map(); //! -//! // O(1) lookup: which measurements does a fault at location L flip? -//! let (has_syndrome, has_logical) = map.classify_fault(0, 1); // loc 0, X fault +//! // O(1) lookup: which detector/non-detector outputs does a fault at location L flip? +//! let (has_syndrome, _flips_non_detector_output) = map.classify_fault(0, 1); // loc 0, X fault //! ``` //! +//! Observables and tracked Paulis are distinct. Observables are values +//! observed through measurement-record parities and become standard `L` +//! outputs in DEM text. Tracked Paulis are Pauli operators annotated at +//! circuit points; they are not measured and are not applied to the computation. +//! PECOS records whether each fault anticommutes with, and therefore would flip, +//! the propagated operator. +//! //! # Concept //! //! Instead of forward propagation: @@ -89,14 +97,15 @@ mod checker; pub mod dag; mod pauli; mod tick; -mod tick_soa; +mod tick_batched; pub mod types; // Re-export from submodules pub use checker::InfluenceBasedChecker; pub use dag::{ BucketRecorder, CsrArray, DagFaultAnalyzer, DagFaultInfluenceMap, DagSpacetimeLocation, - FaultLocations, InfluencesSoA, InfluencesSoAStats, SoARecorderBuilder, + DemOutputKind, DemOutputMetadata, FaultCombo, FaultComponent, FaultEffect, FaultLocations, + GateFaultLocation, InfluencesSoA, InfluencesSoAStats, SoARecorderBuilder, }; pub use pauli::{ Direction, apply_gate, init_pauli_prop_with_fault, propagate_backward_from_tick, @@ -104,10 +113,10 @@ pub use pauli::{ propagate_tick_range, }; pub use tick::TickFaultAnalyzer; -pub use tick_soa::TickFaultAnalyzerSoA; +pub use tick_batched::TickFaultAnalyzerBatched; pub use types::{ - DetectorId, DetectorIdx, FaultInfluence, FaultInfluenceMap, LocationId, LogicalId, LogicalIdx, - MeasurementId, NodeId, Pauli, + DemOutputIdx, DetectorId, DetectorIdx, FaultInfluence, FaultInfluenceMap, LocationId, + MeasurementId, NodeId, Pauli, TrackedPauliId, TrackedPauliIdx, }; // Internal imports @@ -199,7 +208,7 @@ pub trait InfluenceRecorder { /// clean separation between traversal and recording. #[derive(Debug)] pub struct PropagationContext<'a> { - /// Current Pauli observable being propagated. + /// Current Pauli operator being propagated. pub prop: PauliProp, /// Work buffers for traversal. pub buffers: &'a mut PropagatorWorkBuffers, @@ -218,17 +227,17 @@ impl<'a> PropagationContext<'a> { } } - /// Initializes the Pauli observable for a Z-basis measurement. + /// Initializes the Pauli operator for a Z-basis measurement. pub fn init_z_measurement(&mut self, qubit: usize) { self.prop.track_z(&[qubit]); } - /// Initializes the Pauli observable for an X-basis measurement. + /// Initializes the Pauli operator for an X-basis measurement. pub fn init_x_measurement(&mut self, qubit: usize) { self.prop.track_x(&[qubit]); } - /// Initializes the Pauli observable based on measurement basis. + /// Initializes the Pauli operator based on measurement basis. pub fn init_measurement(&mut self, qubit: usize, basis: u8) { if basis == 0 { self.prop.track_z(&[qubit]); @@ -354,7 +363,7 @@ impl InfluenceRecorder for CountingRecorder { if obs_x { self.by_pauli[3] += 1; // Z fault } - if obs_x || obs_z { + if obs_x ^ obs_z { self.by_pauli[2] += 1; // Y fault } } @@ -766,8 +775,10 @@ mod tests { // Get any fault location and check classification if let Some((loc, _)) = map.influences.iter().next() { - let (has_syndrome, has_logical) = checker.classify(loc, 1); // X fault - println!("Location {loc:?}: syndrome={has_syndrome}, logical={has_logical}"); + let (has_syndrome, flips_tracked_pauli) = checker.classify(loc, 1); // X fault + println!( + "Location {loc:?}: syndrome={has_syndrome}, tracked_pauli={flips_tracked_pauli}" + ); } } @@ -950,33 +961,36 @@ mod tests { } #[test] - fn test_backward_vs_forward_with_logicals() { - // Test that logical tracking works with backward propagation + fn test_backward_vs_forward_with_tracked_paulis() { + // Test that tracked-Pauli propagation works with backward propagation let mut circuit = TickCircuit::new(); circuit.tick().pz(&[0, 1, 2]); circuit.tick().cx(&[(0, 2)]); circuit.tick().cx(&[(1, 2)]); circuit.tick().mz(&[2]); - // Define a simple logical Z = Z0 Z1 - let logicals: &[(&[usize], &[usize])] = &[(&[], &[0, 1])]; + // Define a simple tracked Z Pauli = Z0 Z1 + let tracked_paulis: &[(&[usize], &[usize])] = &[(&[], &[0, 1])]; let propagator = TickFaultAnalyzer::new(&circuit); - let map = propagator.build_influence_map_with_logicals(logicals); + let map = propagator.build_influence_map_with_tracked_paulis(tracked_paulis); - // Check that logical tracking is populated - assert_eq!(map.logicals.len(), 1); + // Check that tracked-Pauli propagation is populated + assert_eq!(map.tracked_paulis.len(), 1); - // X errors on data qubits should flip the logical - let mut found_logical_flip = false; + // X errors on data qubits should flip the tracked Pauli + let mut found_tracked_pauli_flip = false; for (loc, influence) in &map.influences { if loc.qubits.iter().any(|q| q.index() == 0 || q.index() == 1) - && !influence.logicals_for_pauli(1).is_empty() + && !influence.tracked_paulis_for_pauli(1).is_empty() { - found_logical_flip = true; + found_tracked_pauli_flip = true; } } - assert!(found_logical_flip, "Should find X errors that flip logical"); + assert!( + found_tracked_pauli_flip, + "Should find X errors that flip tracked Pauli" + ); } #[test] @@ -1073,6 +1087,149 @@ mod tests { .collect() } + fn pauli_prop_from_signature(signature: &[(bool, bool)]) -> PauliProp { + let mut prop = PauliProp::new(); + for (qubit, &(has_x, has_z)) in signature.iter().enumerate() { + if has_x { + prop.track_x(&[qubit]); + } + if has_z { + prop.track_z(&[qubit]); + } + } + prop + } + + fn add_standard_clifford_gate(circuit: &mut TickCircuit, gate_type: GateType) { + match gate_type { + GateType::I => { + circuit.tick().iden(&[0]); + } + GateType::X => { + circuit.tick().x(&[0]); + } + GateType::Y => { + circuit.tick().y(&[0]); + } + GateType::Z => { + circuit.tick().z(&[0]); + } + GateType::H => { + circuit.tick().h(&[0]); + } + GateType::F => { + circuit.tick().f(&[0]); + } + GateType::Fdg => { + circuit.tick().fdg(&[0]); + } + GateType::SX => { + circuit.tick().sx(&[0]); + } + GateType::SXdg => { + circuit.tick().sxdg(&[0]); + } + GateType::SY => { + circuit.tick().sy(&[0]); + } + GateType::SYdg => { + circuit.tick().sydg(&[0]); + } + GateType::SZ => { + circuit.tick().sz(&[0]); + } + GateType::SZdg => { + circuit.tick().szdg(&[0]); + } + GateType::CX => { + circuit.tick().cx(&[(0, 1)]); + } + GateType::CY => { + circuit.tick().cy(&[(0, 1)]); + } + GateType::CZ => { + circuit.tick().cz(&[(0, 1)]); + } + GateType::SXX => { + circuit.tick().sxx(&[(0, 1)]); + } + GateType::SXXdg => { + circuit.tick().sxxdg(&[(0, 1)]); + } + GateType::SYY => { + circuit.tick().syy(&[(0, 1)]); + } + GateType::SYYdg => { + circuit.tick().syydg(&[(0, 1)]); + } + GateType::SZZ => { + circuit.tick().szz(&[(0, 1)]); + } + GateType::SZZdg => { + circuit.tick().szzdg(&[(0, 1)]); + } + GateType::SWAP => { + circuit.tick().swap(&[(0, 1)]); + } + _ => unreachable!("not a standard Clifford gate: {gate_type:?}"), + } + } + + fn assert_pauli_signature_after_gate( + gate_type: GateType, + input: [(bool, bool); 2], + expected: [(bool, bool); 2], + ) { + let mut circuit = TickCircuit::new(); + add_standard_clifford_gate(&mut circuit, gate_type); + + let mut prop = pauli_prop_from_signature(&input); + propagate_through_circuit(&circuit, &mut prop, Direction::Forward); + + assert_eq!( + pauli_signature(&prop, &[0, 1]), + expected, + "{gate_type:?} should map {input:?} to {expected:?} up to Pauli phase" + ); + } + + #[test] + fn test_standard_clifford_pauli_conjugation_tables() { + const I: (bool, bool) = (false, false); + const X: (bool, bool) = (true, false); + const Z: (bool, bool) = (false, true); + const Y: (bool, bool) = (true, true); + + assert_pauli_signature_after_gate(GateType::H, [X, I], [Z, I]); + assert_pauli_signature_after_gate(GateType::H, [Z, I], [X, I]); + assert_pauli_signature_after_gate(GateType::F, [X, I], [Y, I]); + assert_pauli_signature_after_gate(GateType::F, [Y, I], [Z, I]); + assert_pauli_signature_after_gate(GateType::F, [Z, I], [X, I]); + assert_pauli_signature_after_gate(GateType::Fdg, [X, I], [Z, I]); + assert_pauli_signature_after_gate(GateType::Fdg, [Y, I], [X, I]); + assert_pauli_signature_after_gate(GateType::Fdg, [Z, I], [Y, I]); + assert_pauli_signature_after_gate(GateType::SX, [Z, I], [Y, I]); + assert_pauli_signature_after_gate(GateType::SY, [X, I], [Z, I]); + assert_pauli_signature_after_gate(GateType::SZ, [X, I], [Y, I]); + + assert_pauli_signature_after_gate(GateType::CX, [X, I], [X, X]); + assert_pauli_signature_after_gate(GateType::CX, [I, Z], [Z, Z]); + assert_pauli_signature_after_gate(GateType::CY, [X, I], [X, Y]); + assert_pauli_signature_after_gate(GateType::CZ, [X, I], [X, Z]); + assert_pauli_signature_after_gate(GateType::SWAP, [X, Z], [Z, X]); + + assert_pauli_signature_after_gate(GateType::SXX, [Z, I], [Y, X]); + assert_pauli_signature_after_gate(GateType::SXX, [I, Z], [X, Y]); + assert_pauli_signature_after_gate(GateType::SYY, [X, I], [Z, Y]); + assert_pauli_signature_after_gate(GateType::SYY, [I, X], [Y, Z]); + assert_pauli_signature_after_gate(GateType::SZZ, [X, I], [Y, Z]); + assert_pauli_signature_after_gate(GateType::SZZ, [I, X], [Z, Y]); + + assert_pauli_signature_after_gate(GateType::SXXdg, [Z, I], [Y, X]); + assert_pauli_signature_after_gate(GateType::SYYdg, [X, I], [Z, Y]); + assert_pauli_signature_after_gate(GateType::SZZdg, [X, I], [Y, Z]); + } + #[test] fn test_rz_propagation_matches_sz() { let mut rotated = TickCircuit::new(); diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/checker.rs b/crates/pecos-qec/src/fault_tolerance/propagator/checker.rs index 58f0bd5d6..6bc14be8f 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/checker.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/checker.rs @@ -35,11 +35,12 @@ impl<'a> InfluenceBasedChecker<'a> { /// Classifies a fault at the given location with the given Pauli type. /// - /// For single-qubit locations, returns whether any qubit causes syndrome/logical. + /// For single-qubit locations, returns whether any qubit causes syndrome or + /// flips a tracked Pauli. /// For multi-qubit locations where the same Pauli is applied to all qubits, /// use `classify_uniform` which properly handles cancellation effects. /// - /// Returns (`has_syndrome`, `causes_logical_error`). + /// Returns (`has_syndrome`, `flips_tracked_pauli`). #[must_use] pub fn classify(&self, location: &SpacetimeLocation, pauli: u8) -> (bool, bool) { self.influence_map.classify_fault(location, pauli) @@ -53,7 +54,7 @@ impl<'a> InfluenceBasedChecker<'a> { /// For Y faults (single or multi-qubit), we decompose Y = XZ and combine the /// X and Z contributions with XOR semantics. /// - /// Returns (`has_syndrome`, `causes_logical_error`). + /// Returns (`has_syndrome`, `flips_tracked_pauli`). #[must_use] pub fn classify_uniform(&self, location: &SpacetimeLocation, pauli: u8) -> (bool, bool) { // Always use multi-qubit logic for Y faults (even single-qubit) @@ -77,10 +78,10 @@ impl<'a> InfluenceBasedChecker<'a> { }) } - /// Checks if a fault causes an undetectable logical error. + /// Checks if a fault silently flips a tracked Pauli. #[must_use] - pub fn is_undetectable_logical_error(&self, location: &SpacetimeLocation, pauli: u8) -> bool { - let (has_syndrome, has_logical) = self.classify(location, pauli); - !has_syndrome && has_logical + pub fn is_silent_tracked_pauli_flip(&self, location: &SpacetimeLocation, pauli: u8) -> bool { + let (has_syndrome, flips_tracked_pauli) = self.classify(location, pauli); + !has_syndrome && flips_tracked_pauli } } diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs index 8f13a49b1..99d164f04 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/dag.rs @@ -32,6 +32,26 @@ //! - [`DagSpacetimeLocation`]: Identifies a fault location in a DAG circuit //! - [`DagFaultInfluenceMap`]: Cache-optimized influence map using CSR layout //! +//! # Output Terminology +//! +//! The influence map has one detector namespace plus one raw internal +//! non-detector-output namespace. That raw namespace is only a storage detail: +//! metadata maps each raw non-detector output to either a standard observable +//! (`L`) or a PECOS tracked Pauli. Decoder and sampler code should use +//! [`DagFaultInfluenceMap::observable_ids`], +//! [`DagFaultInfluenceMap::observable_id_for_internal_dem_output`], and +//! [`DagFaultInfluenceMap::tracked_pauli_id_for_internal_dem_output`] instead of +//! assuming raw indices are public `L` IDs. +//! +//! Observables and tracked Paulis differ by definition, not just by name. +//! Observables are values observed through measurement-record parities and are +//! visible to DEM decoders as standard `L` outputs. Tracked Paulis are +//! unmeasured Pauli operators annotated at a circuit point, such as logical +//! operators, stabilizers, or other Paulis of interest; the influence map +//! records whether a fault anticommutes with, and therefore would flip, the +//! propagated operator. They are PECOS metadata and are not measurement-record +//! observables. +//! //! # Example //! //! ``` @@ -48,18 +68,18 @@ //! let map = analyzer.build_influence_map(); //! //! // O(1) fault classification -//! let (has_syndrome, has_logical) = map.classify_fault(0, 1); // loc 0, X fault +//! let (has_syndrome, _flips_non_detector_output) = map.classify_fault(0, 1); // loc 0, X fault //! ``` use super::{ DagPropagator, DetectorId, Direction, InfluenceRecorder, MeasurementId, Pauli, apply_gate, }; -use pecos_core::QubitId; use pecos_core::gate_type::GateType; +use pecos_core::{PauliString, QuarterPhase, QubitId}; use pecos_quantum::DagCircuit; use pecos_simulators::PauliProp; use smallvec::SmallVec; -use std::collections::BinaryHeap; +use std::collections::{BTreeMap, BTreeSet, BinaryHeap}; /// Reusable work buffers for propagation, avoiding per-call allocation. pub struct PropagationBuffers { @@ -68,6 +88,13 @@ pub struct PropagationBuffers { pub heap: BinaryHeap<(usize, usize)>, } +struct Phase1Request { + meas_node: usize, + meas_qubit: usize, + basis: u8, + detector_idx: usize, +} + // ============================================================================ // Fault Locations (SoA Layout) // ============================================================================ @@ -179,6 +206,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, }) .collect() } @@ -202,6 +230,8 @@ 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, } // ============================================================================ @@ -348,14 +378,18 @@ pub struct InfluencesSoA { /// Detector indices flipped by Z faults (Pauli=3). pub detectors_z: CsrArray, - /// Logical indices flipped by X faults. - pub logicals_x: CsrArray, + /// Internal non-detector output indices flipped by X faults. + /// + /// These raw indices may name either standard observables or PECOS tracked + /// operators. Use [`DagFaultInfluenceMap`] metadata helpers to map them into + /// the public `L` observable namespace or tracked-Pauli namespace. + pub dem_outputs_x: CsrArray, - /// Logical indices flipped by Y faults. - pub logicals_y: CsrArray, + /// Internal non-detector output indices flipped by Y faults. + pub dem_outputs_y: CsrArray, - /// Logical indices flipped by Z faults. - pub logicals_z: CsrArray, + /// Internal non-detector output indices flipped by Z faults. + pub dem_outputs_z: CsrArray, } impl InfluencesSoA { @@ -369,9 +403,9 @@ impl InfluencesSoA { detectors_x: CsrArray::with_capacity(num_locations, estimated_data), detectors_y: CsrArray::with_capacity(num_locations, estimated_data), detectors_z: CsrArray::with_capacity(num_locations, estimated_data), - logicals_x: CsrArray::with_capacity(num_locations, estimated_data / 4), - logicals_y: CsrArray::with_capacity(num_locations, estimated_data / 4), - logicals_z: CsrArray::with_capacity(num_locations, estimated_data / 4), + dem_outputs_x: CsrArray::with_capacity(num_locations, estimated_data / 4), + dem_outputs_y: CsrArray::with_capacity(num_locations, estimated_data / 4), + dem_outputs_z: CsrArray::with_capacity(num_locations, estimated_data / 4), } } @@ -387,15 +421,21 @@ impl InfluencesSoA { } } - /// Returns the logical indices for a location and Pauli type. + /// Returns raw internal non-detector output indices for a location and Pauli type. + /// + /// These indices are not necessarily standard `L` IDs. Callers that + /// need public observable IDs should use + /// [`DagFaultInfluenceMap::observable_id_for_internal_dem_output`]; callers + /// that need tracked-Pauli IDs should use + /// [`DagFaultInfluenceMap::tracked_pauli_id_for_internal_dem_output`]. #[inline] #[must_use] - pub fn logicals(&self, loc_idx: usize, pauli: Pauli) -> &[u32] { + pub fn dem_outputs(&self, loc_idx: usize, pauli: Pauli) -> &[u32] { match pauli { Pauli::I => &[], - Pauli::X => self.logicals_x.row(loc_idx), - Pauli::Y => self.logicals_y.row(loc_idx), - Pauli::Z => self.logicals_z.row(loc_idx), + Pauli::X => self.dem_outputs_x.row(loc_idx), + Pauli::Y => self.dem_outputs_y.row(loc_idx), + Pauli::Z => self.dem_outputs_z.row(loc_idx), } } @@ -411,27 +451,27 @@ impl InfluencesSoA { } } - /// Returns whether the location has any logical flips for the given Pauli. + /// Returns whether the location has any non-detector output flips for the given Pauli. #[inline] #[must_use] - pub fn has_logical_flips(&self, loc_idx: usize, pauli: Pauli) -> bool { + pub fn has_dem_output_flips(&self, loc_idx: usize, pauli: Pauli) -> bool { match pauli { Pauli::I => false, - Pauli::X => !self.logicals_x.row_is_empty(loc_idx), - Pauli::Y => !self.logicals_y.row_is_empty(loc_idx), - Pauli::Z => !self.logicals_z.row_is_empty(loc_idx), + Pauli::X => !self.dem_outputs_x.row_is_empty(loc_idx), + Pauli::Y => !self.dem_outputs_y.row_is_empty(loc_idx), + Pauli::Z => !self.dem_outputs_z.row_is_empty(loc_idx), } } /// Classifies a fault at the given location. /// - /// Returns (`has_syndrome`, `causes_logical_error`). + /// Returns (`has_syndrome`, `flips_non_detector_output`). #[inline] #[must_use] pub fn classify(&self, loc_idx: usize, pauli: Pauli) -> (bool, bool) { ( self.has_detector_flips(loc_idx, pauli), - self.has_logical_flips(loc_idx, pauli), + self.has_dem_output_flips(loc_idx, pauli), ) } @@ -440,9 +480,9 @@ impl InfluencesSoA { self.detectors_x.finish_row(); self.detectors_y.finish_row(); self.detectors_z.finish_row(); - self.logicals_x.finish_row(); - self.logicals_y.finish_row(); - self.logicals_z.finish_row(); + self.dem_outputs_x.finish_row(); + self.dem_outputs_y.finish_row(); + self.dem_outputs_z.finish_row(); self.num_locations += 1; } @@ -452,17 +492,17 @@ impl InfluencesSoA { let offset_bytes = (self.detectors_x.offsets.len() + self.detectors_y.offsets.len() + self.detectors_z.offsets.len() - + self.logicals_x.offsets.len() - + self.logicals_y.offsets.len() - + self.logicals_z.offsets.len()) + + self.dem_outputs_x.offsets.len() + + self.dem_outputs_y.offsets.len() + + self.dem_outputs_z.offsets.len()) * std::mem::size_of::(); let data_bytes = (self.detectors_x.data.len() + self.detectors_y.data.len() + self.detectors_z.data.len() - + self.logicals_x.data.len() - + self.logicals_y.data.len() - + self.logicals_z.data.len()) + + self.dem_outputs_x.data.len() + + self.dem_outputs_y.data.len() + + self.dem_outputs_z.data.len()) * std::mem::size_of::(); InfluencesSoAStats { @@ -470,23 +510,25 @@ impl InfluencesSoA { total_detector_entries: self.detectors_x.total_elements() + self.detectors_y.total_elements() + self.detectors_z.total_elements(), - total_logical_entries: self.logicals_x.total_elements() - + self.logicals_y.total_elements() - + self.logicals_z.total_elements(), + total_dem_output_entries: self.dem_outputs_x.total_elements() + + self.dem_outputs_y.total_elements() + + self.dem_outputs_z.total_elements(), offset_bytes, data_bytes, total_bytes: offset_bytes + data_bytes, } } - /// Returns the maximum logical index found in the influence map, if any. + /// Returns the maximum raw non-detector output influence index, if any. /// - /// This is useful for determining the number of logical operators tracked. + /// When metadata is present, callers should use [`Self::num_dem_outputs`] + /// for the standard observable `L` namespace and [`Self::num_tracked_paulis`] + /// for PECOS tracked Paulis. #[must_use] - pub fn max_logical_index(&self) -> Option { - let max_x = self.logicals_x.data.iter().max(); - let max_y = self.logicals_y.data.iter().max(); - let max_z = self.logicals_z.data.iter().max(); + pub fn max_dem_output_index(&self) -> Option { + let max_x = self.dem_outputs_x.data.iter().max(); + let max_y = self.dem_outputs_y.data.iter().max(); + let max_z = self.dem_outputs_z.data.iter().max(); [max_x, max_y, max_z] .into_iter() @@ -503,8 +545,8 @@ pub struct InfluencesSoAStats { pub num_locations: usize, /// Total detector entries across all Pauli types. pub total_detector_entries: usize, - /// Total logical entries across all Pauli types. - pub total_logical_entries: usize, + /// Total DEM-output entries across all Pauli types. + pub total_dem_output_entries: usize, /// Bytes used for offset arrays. pub offset_bytes: usize, /// Bytes used for data arrays. @@ -529,7 +571,25 @@ pub struct DagFaultInfluenceMap { pub detectors: Vec, /// All measurements in the circuit (node, qubit, basis). + /// Ordered by `MeasId` when gates carry `MeasId` values. pub measurements: Vec<(usize, usize, u8)>, + + /// `MeasId` IDs for each measurement, in the same order as `measurements`. + /// When populated, `meas_ids[i]` is the stable identity of `measurements[i]`. + /// Empty for legacy circuits without `MeasId` on gates. + pub meas_ids: Vec, + + /// Optional labels for non-detector parity outputs. + /// Indices match the raw non-detector output indices in `influences`. + pub dem_output_labels: Vec>, + + /// Optional metadata for non-detector outputs tracked by backward propagation. + /// + /// These entries may be standard observables or PECOS tracked Paulis. + /// The metadata kind is the authority for translating raw influence indices + /// into public namespaces; standard observables use compact `L` IDs and + /// tracked Paulis use their own compact PECOS-only IDs. + pub dem_output_metadata: Vec, } impl DagFaultInfluenceMap { @@ -541,12 +601,15 @@ impl DagFaultInfluenceMap { locations: Vec::with_capacity(num_locations), detectors: Vec::new(), measurements: Vec::new(), + meas_ids: Vec::new(), + dem_output_labels: Vec::new(), + dem_output_metadata: Vec::new(), } } /// Classifies a fault at the given location index. /// - /// Returns (`has_syndrome`, `causes_logical_error`). + /// Returns (`has_syndrome`, `flips_non_detector_output`). #[inline] #[must_use] pub fn classify_fault(&self, loc_idx: usize, pauli: u8) -> (bool, bool) { @@ -560,11 +623,152 @@ impl DagFaultInfluenceMap { self.influences.detectors(loc_idx, Pauli::from_u8(pauli)) } - /// Returns the logical indices flipped by a fault. + /// Returns all raw non-detector output indices flipped by a fault. + /// + /// Raw indices are an internal storage detail shared by observables and + /// tracked Paulis. Prefer [`Self::get_observable_indices`] or + /// [`Self::get_tracked_pauli_indices`] when a public namespace is needed. + #[inline] + #[must_use] + pub fn get_dem_output_indices(&self, loc_idx: usize, pauli: u8) -> &[u32] { + self.influences.dem_outputs(loc_idx, Pauli::from_u8(pauli)) + } + + /// Returns the number of standard DEM `L` observable outputs. + /// + /// This is a DEM-output alias for [`Self::num_observables`]. It does + /// not include PECOS tracked Paulis. + #[must_use] + pub fn num_dem_outputs(&self) -> usize { + if self.dem_output_metadata.is_empty() { + return self.influences.max_dem_output_index().map_or(0, |i| i + 1); + } + self.dem_output_metadata + .iter() + .filter(|metadata| metadata.kind == DemOutputKind::Observable) + .count() + } + + /// Returns the number of observables. + #[must_use] + pub fn num_observables(&self) -> usize { + self.num_dem_outputs() + } + + /// Returns the standard observable `L` IDs present in this map. + /// + /// Tracked Paulis share internal propagation storage but never appear in + /// this set. Public decoder and sampler paths should use this namespace + /// rather than raw internal DEM-output indices. + #[must_use] + pub fn observable_ids(&self) -> BTreeSet { + (0..self.num_dem_outputs()) + .filter_map(|idx| u32::try_from(idx).ok()) + .collect() + } + + /// Returns the number of PECOS tracked Paulis. + #[must_use] + pub fn num_tracked_paulis(&self) -> usize { + self.dem_output_metadata + .iter() + .filter(|metadata| metadata.kind == DemOutputKind::TrackedPauli) + .count() + } + + /// Returns tracked-Pauli output indices flipped by a fault. + #[must_use] + pub fn get_tracked_pauli_indices(&self, loc_idx: usize, pauli: u8) -> Vec { + let outputs = self.get_dem_output_indices(loc_idx, pauli); + outputs + .iter() + .filter_map(|&idx| self.tracked_pauli_id_for_internal_dem_output(idx)) + .collect() + } + + /// Returns observable output indices flipped by a fault. + #[must_use] + pub fn get_observable_indices(&self, loc_idx: usize, pauli: u8) -> Vec { + let outputs = self.get_dem_output_indices(loc_idx, pauli); + if self.dem_output_metadata.is_empty() { + return outputs.to_vec(); + } + outputs + .iter() + .filter_map(|&idx| self.observable_id_for_internal_dem_output(idx)) + .collect() + } + + /// Map an internal non-detector output index to the standard observable + /// `L` ID space. + #[must_use] + pub fn observable_id_for_internal_dem_output(&self, idx: u32) -> Option { + if self.dem_output_metadata.is_empty() { + return Some(idx); + } + self.output_id_for_kind(idx, DemOutputKind::Observable) + } + + /// Map an internal non-detector output index to the PECOS tracked-Pauli + /// ID space. + #[must_use] + pub fn tracked_pauli_id_for_internal_dem_output(&self, idx: u32) -> Option { + self.output_id_for_kind(idx, DemOutputKind::TrackedPauli) + } + + /// Returns true if a fault flips any non-detector DEM output. #[inline] #[must_use] - pub fn get_logical_indices(&self, loc_idx: usize, pauli: u8) -> &[u32] { - self.influences.logicals(loc_idx, Pauli::from_u8(pauli)) + pub fn has_dem_output_flips(&self, loc_idx: usize, pauli: u8) -> bool { + !self.get_dem_output_indices(loc_idx, pauli).is_empty() + } + + /// Returns true if a fault flips any tracked Pauli. + #[must_use] + pub fn has_tracked_pauli_flips(&self, loc_idx: usize, pauli: u8) -> bool { + !self.get_tracked_pauli_indices(loc_idx, pauli).is_empty() + } + + /// Returns true if a fault flips any observable. + #[must_use] + pub fn has_observable_flips(&self, loc_idx: usize, pauli: u8) -> bool { + !self.get_observable_indices(loc_idx, pauli).is_empty() + } + + /// Returns the label for a detector, if any. + #[inline] + #[must_use] + pub fn detector_label(&self, detector_idx: usize) -> Option<&str> { + self.detectors + .get(detector_idx) + .and_then(|d| d.name.as_deref()) + } + + /// Returns the label for a DEM output, if any. + #[inline] + #[must_use] + pub fn dem_output_label(&self, dem_output_idx: usize) -> Option<&str> { + self.dem_output_labels + .get(dem_output_idx) + .and_then(|l| l.as_deref()) + } + + /// Returns metadata for a DEM output, if available. + #[inline] + #[must_use] + pub fn dem_output_metadata(&self, dem_output_idx: usize) -> Option<&DemOutputMetadata> { + self.dem_output_metadata.get(dem_output_idx) + } + + /// Replace this map's backward-propagated non-detector outputs with + /// another map's outputs and metadata. + pub fn merge_dem_outputs_from(&mut self, other: &Self) { + self.influences.dem_outputs_x = other.influences.dem_outputs_x.clone(); + self.influences.dem_outputs_y = other.influences.dem_outputs_y.clone(); + self.influences.dem_outputs_z = other.influences.dem_outputs_z.clone(); + self.dem_output_labels.clone_from(&other.dem_output_labels); + self.dem_output_metadata + .clone_from(&other.dem_output_metadata); } /// Returns the location at the given index. @@ -589,14 +793,19 @@ impl DagFaultInfluenceMap { /// Export CSR data for GPU use. /// + /// The exported DEM-output arrays contain only standard observable `L` + /// outputs. PECOS tracked Paulis share the internal backward-propagation + /// storage but are intentionally filtered out here so decoder-oriented GPU + /// code cannot count tracked Paulis as logical errors. + /// /// Returns all CSR arrays needed to construct a GPU influence sampler: - /// (`num_locations`, `num_detectors`, `num_logicals`, + /// (`num_locations`, `num_detectors`, `num_dem_outputs`, /// `detector_offsets_x`, `detector_data_x`, /// `detector_offsets_y`, `detector_data_y`, /// `detector_offsets_z`, `detector_data_z`, - /// `logical_offsets_x`, `logical_data_x`, - /// `logical_offsets_y`, `logical_data_y`, - /// `logical_offsets_z`, `logical_data_z`) + /// `dem_output_offsets_x`, `dem_output_data_x`, + /// `dem_output_offsets_y`, `dem_output_data_y`, + /// `dem_output_offsets_z`, `dem_output_data_z`) #[allow(clippy::type_complexity)] #[must_use] pub fn export_csr( @@ -622,29 +831,715 @@ impl DagFaultInfluenceMap { let num_locations = self.locations.len() as u32; #[allow(clippy::cast_possible_truncation)] // detector count fits in u32 let num_detectors = self.detectors.len() as u32; - #[allow(clippy::cast_possible_truncation)] // logical index fits in u32 - let num_logicals = self - .influences - .max_logical_index() - .map_or(0, |i| i as u32 + 1); + #[allow(clippy::cast_possible_truncation)] // DEM-output count fits in u32 + let num_dem_outputs = self.num_dem_outputs() as u32; + let (dem_output_offsets_x, dem_output_data_x) = + self.observable_csr(&self.influences.dem_outputs_x); + let (dem_output_offsets_y, dem_output_data_y) = + self.observable_csr(&self.influences.dem_outputs_y); + let (dem_output_offsets_z, dem_output_data_z) = + self.observable_csr(&self.influences.dem_outputs_z); + + ( + num_locations, + num_detectors, + num_dem_outputs, + self.influences.detectors_x.offsets.clone(), + self.influences.detectors_x.data.clone(), + self.influences.detectors_y.offsets.clone(), + self.influences.detectors_y.data.clone(), + self.influences.detectors_z.offsets.clone(), + self.influences.detectors_z.data.clone(), + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, + ) + } + + fn observable_csr(&self, csr: &CsrArray) -> (Vec, Vec) { + if self.dem_output_metadata.is_empty() { + return (csr.offsets.clone(), csr.data.clone()); + } + + let mut offsets = Vec::with_capacity(csr.offsets.len()); + let mut data = Vec::new(); + offsets.push(0); + for row_idx in 0..csr.num_rows() { + data.extend( + csr.row(row_idx) + .iter() + .filter_map(|&idx| self.observable_id_for_internal_dem_output(idx)), + ); + #[allow(clippy::cast_possible_truncation)] // CSR data length fits in u32 + offsets.push(data.len() as u32); + } + (offsets, data) + } + + fn output_id_for_kind(&self, idx: u32, kind: DemOutputKind) -> Option { + let metadata = self.dem_output_metadata.get(idx as usize)?; + if metadata.kind != kind { + return None; + } + #[allow(clippy::cast_possible_truncation)] // filtered output count fits in u32 + Some( + self.dem_output_metadata[..idx as usize] + .iter() + .filter(|metadata| metadata.kind == kind) + .count() as u32, + ) + } +} + +/// Role of a non-detector output under backward Pauli propagation. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum DemOutputKind { + /// A standard `L` observable defined by measurement records. + Observable, + /// An unmeasured Pauli-operator annotation, separate from measurement records. + TrackedPauli, +} + +impl DemOutputKind { + /// Stable string used by PECOS metadata JSON. + #[must_use] + pub const fn as_str(self) -> &'static str { + match self { + Self::Observable => "observable", + Self::TrackedPauli => "tracked_pauli", + } + } + + /// Parses a stable PECOS metadata string. + #[must_use] + pub fn from_metadata_str(kind: &str) -> Option { + match kind { + "observable" => Some(Self::Observable), + "tracked_pauli" => Some(Self::TrackedPauli), + _ => None, + } + } +} + +/// Metadata for a PECOS non-detector output. +/// +/// Standard DEM text only has `L` observable markers. PECOS keeps this richer +/// record alongside the DEM so callers can distinguish those measurement-record +/// observables from tracked Paulis, which live in a separate +/// PECOS-only namespace. +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct DemOutputMetadata { + /// The output role. + pub kind: DemOutputKind, + /// Pauli string whose flip is tracked. + /// + /// For observables this is the Pauli associated with the measurement-record + /// observable. For tracked Paulis this is the unmeasured tracked Pauli + /// annotated at a circuit point. + pub pauli: PauliString, + /// Optional user label. + pub label: Option, +} + +impl DemOutputMetadata { + /// Creates DEM output metadata. + #[must_use] + pub fn new(kind: DemOutputKind, mut pauli: PauliString, label: Option) -> Self { + // A tracked Pauli op flip is an anticommutation property; global phase has + // no meaning for DEM/sampler output. + pauli.set_phase(QuarterPhase::PlusOne); + Self { kind, pauli, label } + } + + /// Creates metadata for a tracked Pauli. + #[must_use] + pub fn tracked_pauli(pauli: PauliString) -> Self { + Self::new(DemOutputKind::TrackedPauli, pauli, None) + } + + /// Creates metadata for an observable. + #[must_use] + pub fn observable(pauli: PauliString) -> Self { + Self::new(DemOutputKind::Observable, pauli, None) + } + + /// Sets a user-facing op label. + #[must_use] + pub fn with_label(mut self, label: impl Into) -> Self { + self.label = Some(label.into()); + self + } + + /// Sets an optional user-facing op label. + #[must_use] + pub fn with_optional_label(mut self, label: Option) -> Self { + self.label = label; + self + } +} + +// ============================================================================ +// Fault Introspection +// ============================================================================ + +/// A per-gate fault location with access to all possible fault events. +/// +/// Each gate at a specific timing (before/after) is one fault location. +/// Multi-qubit gates have multiple per-qubit sub-locations whose effects +/// compose via XOR (symmetric difference) for multi-qubit Pauli events. +/// +/// Borrows the influence map so you can query events directly: +/// ``` +/// use pecos_qec::fault_tolerance::propagator::dag::DagFaultInfluenceMap; +/// +/// let map = DagFaultInfluenceMap::with_capacity(0); +/// for loc in map.gate_fault_locations() { +/// for event in loc.events() { +/// println!("{}: dets={:?}", event.pauli, event.detectors); +/// } +/// } +/// ``` +pub struct GateFaultLocation<'a> { + map: &'a DagFaultInfluenceMap, + /// DAG node index. + pub node: usize, + /// Gate type. + pub gate_type: GateType, + /// Qubits this gate acts on. + pub qubits: Vec, + /// Before (true) or after (false) the gate. + pub before: bool, + /// Per-qubit location indices in the influence map. + qubit_loc_indices: Vec<(usize, usize)>, // (qubit, loc_idx) +} + +/// The effect of a specific fault event (multi-qubit Pauli error). +#[derive(Debug, Clone)] +pub struct FaultEffect { + /// The multi-qubit Pauli error. + pub pauli: pecos_core::PauliString, + /// Detector indices that flip. + pub detectors: Vec, + /// DEM-output indices that flip. + pub dem_outputs: Vec, + /// Raw measurements that flip: `(node, qubit, basis)`. + /// + /// Derived from the flipped detectors. Each auto-detected detector + /// corresponds to one or more measurements; this expands them. + pub measurements: Vec<(usize, usize, u8)>, +} + +impl FaultEffect { + /// Compose two fault effects (as if both faults occurred). + /// + /// - Paulis are multiplied (handles same-qubit algebra + tensor product) + /// - Detectors, `dem_outputs`, and measurements are XOR'd (symmetric difference) + /// + /// This is the building block for weight-w fault analysis: + /// ``` + /// use pecos_core::PauliString; + /// use pecos_qec::fault_tolerance::propagator::dag::FaultEffect; + /// + /// let effect_a = FaultEffect { + /// pauli: PauliString::x(0), + /// detectors: vec![0], + /// dem_outputs: vec![], + /// measurements: vec![], + /// }; + /// let effect_b = FaultEffect { + /// pauli: PauliString::z(1), + /// detectors: vec![0, 1], + /// dem_outputs: vec![0], + /// measurements: vec![], + /// }; + /// let w2 = effect_a.compose(&effect_b); + /// assert_eq!(w2.detectors, vec![1]); + /// assert_eq!(w2.dem_outputs, vec![0]); + /// ``` + #[must_use] + pub fn compose(&self, other: &Self) -> Self { + let mut pauli = self.pauli.clone() * other.pauli.clone(); + pauli.set_phase(pecos_core::QuarterPhase::PlusOne); + + let mut detectors = self.detectors.clone(); + xor_sorted(&mut detectors, &other.detectors); + + let mut dem_outputs = self.dem_outputs.clone(); + xor_sorted(&mut dem_outputs, &other.dem_outputs); + + let mut measurements = self.measurements.clone(); + xor_sorted_tuples(&mut measurements, &other.measurements); + + Self { + pauli, + detectors, + dem_outputs, + measurements, + } + } +} + +impl GateFaultLocation<'_> { + /// Number of qubits this gate acts on. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.qubits.len() + } + + /// All possible fault events at this location. + /// + /// Only returns multi-qubit Paulis where at least one qubit's + /// single-qubit component has a non-trivial effect in the influence + /// map. E.g., a measurement-before location might only yield X + /// faults since Z before MZ is invisible. + #[must_use] + pub fn possible_faults(&self) -> Vec { + let active = self.active_paulis_per_qubit(); + if active.is_empty() { + return Vec::new(); + } + + let mut combos: Vec> = vec![vec![]]; + for &(q, ref paulis) in &active { + let mut next = Vec::new(); + for existing in &combos { + next.push(existing.clone()); + for &p in paulis { + let mut extended = existing.clone(); + extended.push((q.index(), p)); + next.push(extended); + } + } + combos = next; + } + + combos + .into_iter() + .filter(|c| !c.is_empty()) + .map(|entries| { + pecos_core::PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + entries + .iter() + .map(|&(q, p)| (p, QubitId::from(q))) + .collect(), + ) + }) + .collect() + } + + /// All fault events that have non-trivial effects (flip at least one + /// detector or logical). + #[must_use] + pub fn events(&self) -> Vec { + self.possible_faults() + .into_iter() + .map(|ps| self.query(&ps)) + .filter(|e| !e.detectors.is_empty() || !e.dem_outputs.is_empty()) + .collect() + } + + /// All physically possible fault events, including those with no effect. + /// + /// Use this for probability-correct enumeration (e.g., ML decoder). + /// Events with empty detectors and `dem_outputs` are "trivial" faults that + /// happen with real probability but don't change any observable. + #[must_use] + pub fn all_events(&self) -> Vec { + self.all_physical_paulis() + .into_iter() + .map(|ps| self.query(&ps)) + .collect() + } + + /// All physically meaningful single-qubit Paulis per qubit, regardless + /// of whether they have non-trivial effects in the influence map. + /// + /// Gate-type filtering applies (PZ/MZ only X/Y) but effect filtering + /// does not. This ensures correct probability accounting. + fn all_physical_paulis(&self) -> Vec { + let physical: &[pecos_core::Pauli] = match self.gate_type { + // Z-basis prep/measurement: only X (bit-flip) fault. + GateType::PZ | GateType::QAlloc | GateType::MZ | GateType::MeasureFree => { + &[pecos_core::Pauli::X] + } + // Unitary gates: all single-qubit Paulis. + _ => &[ + pecos_core::Pauli::X, + pecos_core::Pauli::Y, + pecos_core::Pauli::Z, + ], + }; + + // Build all combinations (including I on each qubit) + let mut combos: Vec> = vec![vec![]]; + for &q in &self.qubits { + let mut next = Vec::new(); + for existing in &combos { + next.push(existing.clone()); // I on this qubit + for &p in physical { + let mut extended = existing.clone(); + extended.push((q.index(), p)); + next.push(extended); + } + } + combos = next; + } + + combos + .into_iter() + .filter(|c| !c.is_empty()) + .map(|entries| { + pecos_core::PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + entries + .iter() + .map(|&(q, p)| (p, QubitId::from(q))) + .collect(), + ) + }) + .collect() + } + + /// Query the effect of a specific multi-qubit Pauli event. + #[must_use] + pub fn query(&self, pauli: &pecos_core::PauliString) -> FaultEffect { + let entries: Vec<(usize, pecos_core::Pauli)> = pauli + .paulis() + .iter() + .map(|&(p, q)| (q.index(), p)) + .collect(); + let (detectors, dem_outputs) = self.compose_effects(&entries); + + // Resolve detector indices to raw measurements + let measurements = self.resolve_measurements(&detectors); + + FaultEffect { + pauli: pauli.clone(), + detectors, + dem_outputs, + measurements, + } + } + + /// Which single-qubit Paulis are physically meaningful at each qubit. + /// + /// Filters based on both the influence map (has non-trivial effect) and + /// the gate type (Z after PZ is unphysical, Z before MZ is invisible). + fn active_paulis_per_qubit(&self) -> Vec<(QubitId, Vec)> { + // Determine which Paulis are physical for this gate type + let physical_paulis: &[Pauli] = match self.gate_type { + // Z-basis prep/measurement: only X (bit-flip) fault. + GateType::PZ | GateType::QAlloc | GateType::MZ | GateType::MeasureFree => &[Pauli::X], + // Unitary gates: all single-qubit Paulis. + _ => &[Pauli::X, Pauli::Y, Pauli::Z], + }; + + self.qubit_loc_indices + .iter() + .filter_map(|&(qubit, loc_idx)| { + let mut paulis = Vec::new(); + for &p in physical_paulis { + if self.map.influences.has_detector_flips(loc_idx, p) + || self.map.influences.has_dem_output_flips(loc_idx, p) + { + paulis.push(propagator_to_core_pauli(p)); + } + } + if paulis.is_empty() { + None + } else { + Some((QubitId::from(qubit), paulis)) + } + }) + .collect() + } + + /// Compose per-qubit effects via XOR (symmetric difference). + fn compose_effects(&self, entries: &[(usize, pecos_core::Pauli)]) -> (Vec, Vec) { + let mut det_set: Vec = Vec::new(); + let mut dem_output_set: Vec = Vec::new(); + + for &(qubit, pauli) in entries { + if pauli == pecos_core::Pauli::I { + continue; + } + let prop_pauli = core_to_propagator_pauli(pauli); + + if let Some(&(_, loc_idx)) = self.qubit_loc_indices.iter().find(|&&(q, _)| q == qubit) { + let dets = self.map.influences.detectors(loc_idx, prop_pauli); + xor_sorted(&mut det_set, dets); + let dem_outputs = self.map.influences.dem_outputs(loc_idx, prop_pauli); + xor_sorted(&mut dem_output_set, dem_outputs); + } + } + + (det_set, dem_output_set) + } + + /// Resolve detector indices to raw measurement tuples. + fn resolve_measurements(&self, detector_indices: &[u32]) -> Vec<(usize, usize, u8)> { + let mut measurements = Vec::new(); + for &det_idx in detector_indices { + if let Some(det) = self.map.detectors.get(det_idx as usize) { + for meas_id in &det.measurements { + measurements.push((meas_id.tick, meas_id.qubit, meas_id.basis)); + } + } + } + measurements + } +} + +/// Symmetric difference for sorted `(usize, usize, u8)` tuples (measurements). +fn xor_sorted_tuples(acc: &mut Vec<(usize, usize, u8)>, other: &[(usize, usize, u8)]) { + if other.is_empty() { + return; + } + if acc.is_empty() { + acc.extend_from_slice(other); + return; + } + let mut result = Vec::with_capacity(acc.len() + other.len()); + let mut i = 0; + let mut j = 0; + while i < acc.len() && j < other.len() { + match acc[i].cmp(&other[j]) { + std::cmp::Ordering::Less => { + result.push(acc[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + result.push(other[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + i += 1; + j += 1; + } + } + } + result.extend_from_slice(&acc[i..]); + result.extend_from_slice(&other[j..]); + *acc = result; +} + +/// Convert `pecos_core::Pauli` to the propagator's `Pauli`. +fn core_to_propagator_pauli(p: pecos_core::Pauli) -> Pauli { + match p { + pecos_core::Pauli::I => Pauli::I, + pecos_core::Pauli::X => Pauli::X, + pecos_core::Pauli::Y => Pauli::Y, + pecos_core::Pauli::Z => Pauli::Z, + } +} + +/// Convert the propagator's `Pauli` to `pecos_core::Pauli`. +fn propagator_to_core_pauli(p: Pauli) -> pecos_core::Pauli { + match p { + Pauli::I => pecos_core::Pauli::I, + Pauli::X => pecos_core::Pauli::X, + Pauli::Y => pecos_core::Pauli::Y, + Pauli::Z => pecos_core::Pauli::Z, + } +} + +/// Symmetric difference of two sorted u32 slices, mutating `acc` in place. +fn xor_sorted(acc: &mut Vec, other: &[u32]) { + if other.is_empty() { + return; + } + if acc.is_empty() { + acc.extend_from_slice(other); + return; + } + // Build symmetric difference: elements in exactly one of the two sets + let mut result = Vec::with_capacity(acc.len() + other.len()); + let mut i = 0; + let mut j = 0; + while i < acc.len() && j < other.len() { + match acc[i].cmp(&other[j]) { + std::cmp::Ordering::Less => { + result.push(acc[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + result.push(other[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + // In both sets -- they cancel (XOR) + i += 1; + j += 1; + } + } + } + result.extend_from_slice(&acc[i..]); + result.extend_from_slice(&other[j..]); + *acc = result; +} + +impl DagFaultInfluenceMap { + /// Group per-qubit locations into per-gate fault locations. + /// + /// Each returned [`GateFaultLocation`] represents a gate at a specific + /// timing (before/after) and supports querying multi-qubit Pauli events. + /// + /// ``` + /// use pecos_qec::fault_tolerance::propagator::dag::DagFaultInfluenceMap; + /// + /// let map = DagFaultInfluenceMap::with_capacity(0); + /// for loc in map.gate_fault_locations() { + /// for event in loc.events() { + /// println!("{}: dets={:?} dem_outputs={:?}", event.pauli, event.detectors, event.dem_outputs); + /// } + /// } + /// ``` + #[must_use] + pub fn gate_fault_locations(&self) -> Vec> { + let mut groups: std::collections::BTreeMap<(usize, bool), Vec<(usize, usize)>> = + std::collections::BTreeMap::new(); + + for (loc_idx, loc) in self.locations.iter().enumerate() { + let key = (loc.node, loc.before); + for q in &loc.qubits { + groups.entry(key).or_default().push((q.index(), loc_idx)); + } + } + + groups + .into_iter() + .map(|((node, before), qubit_locs)| { + let gate_type = self.locations[qubit_locs[0].1].gate_type; + let qubits: Vec = + qubit_locs.iter().map(|&(q, _)| QubitId::from(q)).collect(); + GateFaultLocation { + map: self, + node, + gate_type, + qubits, + before, + qubit_loc_indices: qubit_locs, + } + }) + .collect() + } +} + +// ============================================================================ +// Weight-w Fault Enumeration +// ============================================================================ + +/// A single component of a multi-fault combination. +#[derive(Debug, Clone)] +pub struct FaultComponent { + /// Index into `gate_fault_locations()`. + pub location_index: usize, + /// The fault event at this location. + pub event: FaultEffect, +} + +/// A weight-w combination of faults and their combined effect. +#[derive(Debug, Clone)] +pub struct FaultCombo { + /// The individual (location, event) pairs. + pub components: Vec, + /// Combined effect (XOR of all component effects). + pub effect: FaultEffect, +} + +impl DagFaultInfluenceMap { + /// Enumerate all weight-w fault combinations, calling `f` for each. + /// + /// At weight 1, this iterates every fault location and every possible + /// fault event. At weight 2, all pairs of (location, event). And so on. + /// + /// Uses a callback to avoid allocating a potentially huge result vec. + /// + /// ``` + /// use pecos_qec::fault_tolerance::propagator::dag::DagFaultInfluenceMap; + /// + /// let map = DagFaultInfluenceMap::with_capacity(0); + /// // Find all undetectable weight-2 errors + /// map.for_each_fault_combo(2, |combo| { + /// if !combo.effect.dem_outputs.is_empty() && combo.effect.detectors.is_empty() { + /// println!("Undetectable w=2:"); + /// for c in &combo.components { + /// println!(" {} at loc {}", c.event.pauli, c.location_index); + /// } + /// } + /// }); + /// ``` + pub fn for_each_fault_combo(&self, weight: usize, mut f: impl FnMut(&FaultCombo)) { + let locs = self.gate_fault_locations(); + + // Pre-compute events for each location + let all_events: Vec> = + locs.iter().map(GateFaultLocation::events).collect(); + + let empty_effect = FaultEffect { + pauli: pecos_core::PauliString::identity(), + detectors: Vec::new(), + dem_outputs: Vec::new(), + measurements: Vec::new(), + }; - ( - num_locations, - num_detectors, - num_logicals, - self.influences.detectors_x.offsets.clone(), - self.influences.detectors_x.data.clone(), - self.influences.detectors_y.offsets.clone(), - self.influences.detectors_y.data.clone(), - self.influences.detectors_z.offsets.clone(), - self.influences.detectors_z.data.clone(), - self.influences.logicals_x.offsets.clone(), - self.influences.logicals_x.data.clone(), - self.influences.logicals_y.offsets.clone(), - self.influences.logicals_y.data.clone(), - self.influences.logicals_z.offsets.clone(), - self.influences.logicals_z.data.clone(), - ) + let mut components = Vec::with_capacity(weight); + let mut effects_stack = vec![empty_effect]; + + enumerate_combos( + &all_events, + weight, + 0, // start_loc + &mut components, + &mut effects_stack, + &mut f, + ); + } +} + +/// Recursive helper for weight-w combination enumeration. +fn enumerate_combos( + all_events: &[Vec], + remaining: usize, + start_loc: usize, + components: &mut Vec, + effects_stack: &mut Vec, + f: &mut impl FnMut(&FaultCombo), +) { + if remaining == 0 { + f(&FaultCombo { + components: components.clone(), + effect: effects_stack.last().unwrap().clone(), + }); + return; + } + + for loc_idx in start_loc..all_events.len() { + for event in &all_events[loc_idx] { + let combined = effects_stack.last().unwrap().compose(event); + + components.push(FaultComponent { + location_index: loc_idx, + event: event.clone(), + }); + effects_stack.push(combined); + + enumerate_combos( + all_events, + remaining - 1, + loc_idx + 1, // no repeats + components, + effects_stack, + f, + ); + + components.pop(); + effects_stack.pop(); + } } } @@ -779,8 +1674,8 @@ impl InfluenceRecorder for BucketRecorder { if obs_x { self.z_buckets[loc_idx].push(det); } - // Y fault anticommutes with X or Z observable - if obs_x || obs_z { + // Y fault anticommutes with X or Z but NOT both (Y commutes with Y) + if obs_x ^ obs_z { self.y_buckets[loc_idx].push(det); } } @@ -873,28 +1768,26 @@ impl<'a> DagFaultAnalyzer<'a> { for &node in &topo_order { if let Some(gate) = propagator.gate(node) { + // Skip meta-gates — they don't create fault locations + if gate.gate_type.is_meta() { + continue; + } + let is_measurement = matches!(gate.gate_type, GateType::MZ | GateType::MeasureFree); - let is_prep = matches!(gate.gate_type, GateType::PZ | GateType::QAlloc); // Convert QubitId to usize let qubits: SmallVec<[usize; 2]> = gate.qubits.iter().map(pecos_core::QubitId::index).collect(); - // Create per-qubit fault locations for proper depolarizing noise analysis + // Standard circuit noise model: one fault location per gate. + // Measurement: before (X flip before readout) + // All others (prep, unitary, idle): after + // Idle gates on non-active qubits provide the missing "before" + // coverage that would otherwise require before-gate locations. + let before = is_measurement; for &q in &qubits { let single_qubit: SmallVec<[usize; 2]> = smallvec::smallvec![q]; - - if is_measurement { - // Measurements only have before=true locations - locations.push(node, single_qubit, true, gate.gate_type); - } else if is_prep { - // Preps only have before=false locations - locations.push(node, single_qubit, false, gate.gate_type); - } else { - // Regular gates have both before and after locations - locations.push(node, single_qubit.clone(), true, gate.gate_type); - locations.push(node, single_qubit, false, gate.gate_type); - } + locations.push(node, single_qubit, before, gate.gate_type); } } } @@ -934,8 +1827,9 @@ impl<'a> DagFaultAnalyzer<'a> { map.locations = self.locations.to_dag_spacetime_locations(); // Extract measurements and create detectors - let measurements = self.extract_measurements(); + let (measurements, meas_ids) = self.extract_measurements(); map.measurements.clone_from(&measurements); + map.meas_ids = meas_ids; for &(node, qubit, basis) in &measurements { let measurement_id = MeasurementId { @@ -946,11 +1840,8 @@ impl<'a> DagFaultAnalyzer<'a> { map.detectors.push(DetectorId::single(measurement_id)); } - // Use bucket recorder for O(n) construction - let mut recorder = BucketRecorder::new(num_locations); - - // Propagate using the generic method with bucket recorder - self.propagate_all(&mut recorder); + // Use forest propagation: per-ancilla Phase 1/Phase 2 split. + let recorder = self.propagate_all_forest(); // Convert buckets to SoA format (O(n) flattening) map.influences = recorder.into_soa(); @@ -964,12 +1855,17 @@ impl<'a> DagFaultAnalyzer<'a> { /// 1. Topological position (to respect causal dependencies) /// 2. Qubit index (to break ties for concurrent/independent measurements) /// - /// This ensures the measurement ordering matches Stim's convention where - /// measurements on lower-indexed qubits appear first when they're in the - /// same "layer" of the circuit. + /// This gives deterministic measurement ordering where measurements on + /// lower-indexed qubits appear first when they are in the same "layer" of + /// the circuit. #[must_use] - pub fn extract_measurements(&self) -> Vec<(usize, usize, u8)> { - let mut measurements = Vec::new(); + /// Extract measurements with optional `MeasId` IDs. + /// + /// Returns `(measurements, meas_ids)` where: + /// - `measurements` is `Vec<(node, qubit, basis)>` in `MeasId` order + /// - `meas_ids` is `Vec` (empty for legacy circuits) + pub fn extract_measurements(&self) -> (Vec<(usize, usize, u8)>, Vec) { + let mut entries = Vec::new(); // (sort_key, qubit, node, basis, Option) for &node in self.propagator.topo_order() { if let Some(gate) = self.propagator.gate(node) { @@ -978,23 +1874,39 @@ impl<'a> DagFaultAnalyzer<'a> { _ => continue, }; - let topo_pos = self.propagator.topo_position(node); - for qubit in &gate.qubits { - // Store (topo_pos, qubit, node, basis) for sorting - measurements.push((topo_pos, qubit.index(), node, basis)); + if gate.meas_ids.is_empty() { + let topo_pos = self.propagator.topo_position(node); + for qubit in &gate.qubits { + entries.push((topo_pos, qubit.index(), node, basis, None)); + } + } else { + for (i, qubit) in gate.qubits.iter().enumerate() { + let mr = gate.meas_ids.get(i).copied(); + let sort_key = mr.map_or(usize::MAX, pecos_core::MeasId::index); + entries.push((sort_key, qubit.index(), node, basis, mr)); + } } } } - // Sort by (topological_position, qubit_index) for deterministic ordering - // This ensures measurements on lower-indexed qubits come first when concurrent - measurements.sort_by_key(|&(topo_pos, qubit, _, _)| (topo_pos, qubit)); + entries.sort_by_key(|&(sort_key, qubit, _, _, _)| (sort_key, qubit)); - // Return in the expected format: (node, qubit, basis) - measurements + let has_meas_ids = entries.iter().any(|(_, _, _, _, mr)| mr.is_some()); + let meas_ids = if has_meas_ids { + entries + .iter() + .map(|(_, _, _, _, mr)| mr.unwrap_or(pecos_core::MeasId(usize::MAX))) + .collect() + } else { + Vec::new() + }; + + let measurements = entries .into_iter() - .map(|(_, qubit, node, basis)| (node, qubit, basis)) - .collect() + .map(|(_, qubit, node, basis, _)| (node, qubit, basis)) + .collect(); + + (measurements, meas_ids) } // ========================================================================= @@ -1152,6 +2064,320 @@ impl<'a> DagFaultAnalyzer<'a> { } } + // ==================================================================== + // Forest propagation: per-ancilla Phase 1 / Phase 2 split + // ==================================================================== + + /// Captured influence entry from Phase 2 (shared tail below PZ). + /// Stored with `topo_pos` for prefix slicing across measurements. + /// + /// Phase 1: propagate from MZ backward through within-round gates, + /// stopping at the ancilla's PZ. Records influences normally. + /// Returns the PZ node's topo position, or None if no PZ was hit. + /// + /// After return, `work.heap` still contains data qubit gates below + /// the PZ — ready for Phase 2. + fn propagate_phase1( + &self, + request: &Phase1Request, + recorder: &mut R, + work: &mut PropagationBuffers, + prop: &mut PauliProp, + ) -> Option { + let visited = &mut work.visited; + let active_qubits = &mut work.active_qubits; + let heap = &mut work.heap; + visited.fill(false); + active_qubits.fill(false); + heap.clear(); + + *prop = PauliProp::new(); + if request.basis == 0 { + prop.track_z(&[request.meas_qubit]); + } else { + prop.track_x(&[request.meas_qubit]); + } + + let meas_topo_pos = self.propagator.topo_position(request.meas_node); + self.record_at_node_generic( + request.meas_node, + prop, + request.detector_idx, + recorder, + true, + ); + + if request.meas_qubit <= self.max_qubit() { + active_qubits[request.meas_qubit] = true; + for (topo_pos, node) in self.propagator.qubit_gates_backward(request.meas_qubit) { + if topo_pos < meas_topo_pos && !visited[node] { + visited[node] = true; + heap.push((topo_pos, node)); + } + } + } + + while let Some((_, node)) = heap.pop() { + if let Some(gate) = self.propagator.gate(node) { + let mut was_active = [false; 8]; + for (j, q) in gate.qubits.iter().enumerate() { + if j < was_active.len() && q.index() <= self.max_qubit() { + was_active[j] = active_qubits[q.index()]; + } + } + + self.record_at_node_generic(node, prop, request.detector_idx, recorder, false); + + if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + let pz_topo = self.propagator.topo_position(node); + for q in &gate.qubits { + let idx = q.index(); + if idx <= self.max_qubit() { + if prop.contains_x(idx) { + prop.track_x(&[idx]); + } + if prop.contains_z(idx) { + prop.track_z(&[idx]); + } + active_qubits[idx] = false; + } + } + // Stop Phase 1 — data qubit gates remain in the heap. + return Some(pz_topo); + } + + apply_gate(prop, gate, Direction::Backward); + self.record_at_node_generic(node, prop, request.detector_idx, recorder, true); + + let node_topo_pos = self.propagator.topo_position(node); + for (j, q) in gate.qubits.iter().enumerate() { + let idx = q.index(); + if idx <= self.max_qubit() { + let now_active = prop.contains_x(idx) || prop.contains_z(idx); + let was = j < was_active.len() && was_active[j]; + if now_active && !was { + active_qubits[idx] = true; + for (topo_pos, new_node) in self.propagator.qubit_gates_backward(idx) { + if topo_pos < node_topo_pos && !visited[new_node] { + visited[new_node] = true; + heap.push((topo_pos, new_node)); + } + } + } else if !now_active && was { + active_qubits[idx] = false; + } + } + } + } + } + None // No PZ hit (e.g., first round init detectors) + } + + /// Phase 2: continue backward propagation from data qubit frontier + /// below PZ. Records to `recorder` AND captures the visited node + /// sequence for replay. + fn propagate_phase2_capture( + &self, + detector_idx: usize, + recorder: &mut R, + work: &mut PropagationBuffers, + prop: &mut PauliProp, + ) -> Vec { + let mut visited_nodes: Vec = Vec::new(); + let visited = &mut work.visited; + let active_qubits = &mut work.active_qubits; + let heap = &mut work.heap; + + while let Some((_, node)) = heap.pop() { + if let Some(gate) = self.propagator.gate(node) { + let mut was_active = [false; 8]; + for (j, q) in gate.qubits.iter().enumerate() { + if j < was_active.len() && q.index() <= self.max_qubit() { + was_active[j] = active_qubits[q.index()]; + } + } + + self.record_at_node_generic(node, prop, detector_idx, recorder, false); + visited_nodes.push(node); + + if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + for q in &gate.qubits { + let idx = q.index(); + if idx <= self.max_qubit() { + if prop.contains_x(idx) { + prop.track_x(&[idx]); + } + if prop.contains_z(idx) { + prop.track_z(&[idx]); + } + active_qubits[idx] = false; + } + } + continue; + } + + apply_gate(prop, gate, Direction::Backward); + self.record_at_node_generic(node, prop, detector_idx, recorder, true); + + let node_topo = self.propagator.topo_position(node); + for (j, q) in gate.qubits.iter().enumerate() { + let idx = q.index(); + if idx <= self.max_qubit() { + let now_active = prop.contains_x(idx) || prop.contains_z(idx); + let was = j < was_active.len() && was_active[j]; + if now_active && !was { + active_qubits[idx] = true; + for (topo_pos, new_node) in self.propagator.qubit_gates_backward(idx) { + if topo_pos < node_topo && !visited[new_node] { + visited[new_node] = true; + heap.push((topo_pos, new_node)); + } + } + } else if !now_active && was { + active_qubits[idx] = false; + } + } + } + } + } + visited_nodes + } + + /// Replay Phase 2 using a cached node sequence. + /// + /// Iterates the captured nodes, re-applies gates backward to the Pauli + /// state, and re-records with the correct `obs_x/obs_z` values. No heap + /// or visited array needed — just a flat loop over known nodes. + fn replay_phase2( + &self, + nodes: &[usize], + pz_topo: usize, + detector_idx: usize, + recorder: &mut R, + prop: &mut PauliProp, + ) { + for &node in nodes { + if self.propagator.topo_position(node) >= pz_topo { + continue; + } + + if let Some(gate) = self.propagator.gate(node) { + self.record_at_node_generic(node, prop, detector_idx, recorder, false); + + if matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { + for q in &gate.qubits { + let idx = q.index(); + if idx <= self.max_qubit() { + if prop.contains_x(idx) { + prop.track_x(&[idx]); + } + if prop.contains_z(idx) { + prop.track_z(&[idx]); + } + } + } + continue; + } + + apply_gate(prop, gate, Direction::Backward); + self.record_at_node_generic(node, prop, detector_idx, recorder, true); + } + } + } + + /// Parallel forest propagation: groups measurements by ancilla qubit, + /// propagates the latest measurement fully with capture, replays the + /// shared tail prefix for earlier measurements. + #[must_use] + pub fn propagate_all_forest(&self) -> BucketRecorder { + use rayon::prelude::*; + + let (measurements, _meas_ids) = self.extract_measurements(); + let num_locations = self.locations.len(); + + // Group measurement indices by qubit (ancilla). + let mut by_qubit: BTreeMap> = BTreeMap::new(); + for (det_idx, &(_, qubit, _)) in measurements.iter().enumerate() { + by_qubit.entry(qubit).or_default().push(det_idx); + } + + // Collect ancilla groups for parallel iteration. + let groups: Vec> = by_qubit.into_values().collect(); + + let per_thread: Vec = groups + .par_iter() + .map(|det_indices| { + let mut recorder = BucketRecorder::new(num_locations); + let mut work = PropagationBuffers { + visited: vec![false; self.propagator.max_node() + 1], + active_qubits: vec![false; self.propagator.max_qubit() + 1], + heap: BinaryHeap::with_capacity(64), + }; + + // Sort by topo position (ascending = earliest first). + let mut sorted = det_indices.clone(); + sorted.sort_by_key(|&i| self.propagator.topo_position(measurements[i].0)); + + // Latest measurement: Phase 1 + Phase 2 with capture + let Some(&latest) = sorted.last() else { + return recorder; + }; + let (l_node, l_qubit, l_basis) = measurements[latest]; + + let mut prop = PauliProp::new(); + + let latest_request = Phase1Request { + meas_node: l_node, + meas_qubit: l_qubit, + basis: l_basis, + detector_idx: latest, + }; + let _pz_topo = + self.propagate_phase1(&latest_request, &mut recorder, &mut work, &mut prop); + let tail_capture = + self.propagate_phase2_capture(latest, &mut recorder, &mut work, &mut prop); + + // Earlier measurements: Phase 1 + replay tail with correct Pauli state + for &det_idx in sorted[..sorted.len() - 1].iter().rev() { + let (m_node, m_qubit, m_basis) = measurements[det_idx]; + + let request = Phase1Request { + meas_node: m_node, + meas_qubit: m_qubit, + basis: m_basis, + detector_idx: det_idx, + }; + let pz_topo_i = + self.propagate_phase1(&request, &mut recorder, &mut work, &mut prop); + + // Replay cached node sequence with correct Pauli state + if let Some(pz_pos) = pz_topo_i { + self.replay_phase2( + &tail_capture, + pz_pos, + det_idx, + &mut recorder, + &mut prop, + ); + } + } + + recorder + }) + .collect(); + + // Merge all recorders + let mut merged = BucketRecorder::new(num_locations); + for rec in per_thread { + for i in 0..num_locations { + merged.x_buckets[i].extend(rec.x_buckets[i].iter().copied()); + merged.y_buckets[i].extend(rec.y_buckets[i].iter().copied()); + merged.z_buckets[i].extend(rec.z_buckets[i].iter().copied()); + } + } + merged + } + /// Builds a fault influence map using a custom recorder. /// /// This is the most flexible method, allowing custom recording strategies. @@ -1176,7 +2402,7 @@ impl<'a> DagFaultAnalyzer<'a> { /// println!("Total influences: {}", recorder.count); /// ``` pub fn propagate_all(&self, recorder: &mut R) { - let measurements = self.extract_measurements(); + let (measurements, _) = self.extract_measurements(); let mut work = PropagationBuffers { visited: vec![false; self.propagator.max_node() + 1], @@ -1195,6 +2421,55 @@ impl<'a> DagFaultAnalyzer<'a> { ); } } + + /// Parallel version: propagates from all measurements using rayon. + /// Each thread gets its own `BucketRecorder`, results are merged. + #[must_use] + pub fn propagate_all_parallel(&self) -> BucketRecorder { + use rayon::prelude::*; + + let (measurements, _) = self.extract_measurements(); + let num_locations = self.locations.len(); + + let chunk_size = measurements.len().div_ceil(rayon::current_num_threads()); + + let per_thread: Vec = measurements + .par_chunks(chunk_size.max(1)) + .enumerate() + .map(|(chunk_idx, chunk)| { + let base_idx = chunk_idx * chunk_size; + let mut recorder = BucketRecorder::new(num_locations); + let mut work = PropagationBuffers { + visited: vec![false; self.propagator.max_node() + 1], + active_qubits: vec![false; self.propagator.max_qubit() + 1], + heap: BinaryHeap::with_capacity(64), + }; + + for (i, &(node, qubit, basis)) in chunk.iter().enumerate() { + self.propagate_from_measurement_generic( + node, + qubit, + basis, + base_idx + i, + &mut recorder, + &mut work, + ); + } + recorder + }) + .collect(); + + // Merge all recorders + let mut merged = BucketRecorder::new(num_locations); + for rec in per_thread { + for i in 0..num_locations { + merged.x_buckets[i].extend(rec.x_buckets[i].iter().copied()); + merged.y_buckets[i].extend(rec.y_buckets[i].iter().copied()); + merged.z_buckets[i].extend(rec.z_buckets[i].iter().copied()); + } + } + merged + } } #[cfg(test)] @@ -1370,12 +2645,14 @@ mod tests { qubits: vec![QubitId::from(0)], before: true, gate_type: GateType::H, + idle_duration: 0, }; let loc2 = DagSpacetimeLocation { node: 1, qubits: vec![QubitId::from(0)], before: true, gate_type: GateType::H, + idle_duration: 0, }; assert!(loc1 < loc2); } @@ -1414,9 +2691,9 @@ mod tests { soa.detectors_y.finish_row(); soa.detectors_z.finish_row(); soa.detectors_x.finish_row(); - soa.logicals_x.finish_row(); - soa.logicals_y.finish_row(); - soa.logicals_z.finish_row(); + soa.dem_outputs_x.finish_row(); + soa.dem_outputs_y.finish_row(); + soa.dem_outputs_z.finish_row(); soa.num_locations += 1; // Location 1: Z flips detector 1 @@ -1424,9 +2701,9 @@ mod tests { soa.detectors_y.finish_row(); soa.detectors_z.push(1); soa.detectors_z.finish_row(); - soa.logicals_x.finish_row(); - soa.logicals_y.finish_row(); - soa.logicals_z.finish_row(); + soa.dem_outputs_x.finish_row(); + soa.dem_outputs_y.finish_row(); + soa.dem_outputs_z.finish_row(); soa.num_locations += 1; assert!(soa.has_detector_flips(0, Pauli::X)); @@ -1435,6 +2712,106 @@ mod tests { assert!(soa.has_detector_flips(1, Pauli::Z)); } + #[test] + fn test_export_csr_filters_tracked_paulis_from_dem_outputs() { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.h(&[0]); + + let map = crate::fault_tolerance::InfluenceBuilder::new(&dag) + .with_z(&[0]) + .build(); + + assert_eq!(map.num_dem_outputs(), 0); + assert_eq!(map.num_tracked_paulis(), 1); + assert!( + map.influences.max_dem_output_index().is_some(), + "tracked Pauli should still use internal propagation storage" + ); + + let ( + _num_locations, + _num_detectors, + num_dem_outputs, + _detector_offsets_x, + _detector_data_x, + _detector_offsets_y, + _detector_data_y, + _detector_offsets_z, + _detector_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, + ) = map.export_csr(); + + assert_eq!(num_dem_outputs, 0); + assert!(dem_output_data_x.is_empty()); + assert!(dem_output_data_y.is_empty()); + assert!(dem_output_data_z.is_empty()); + assert_eq!(dem_output_offsets_x.len(), map.locations.len() + 1); + assert_eq!(dem_output_offsets_y.len(), map.locations.len() + 1); + assert_eq!(dem_output_offsets_z.len(), map.locations.len() + 1); + } + + #[test] + fn test_dem_output_helpers_use_separate_compact_id_spaces() { + let mut map = DagFaultInfluenceMap::with_capacity(1); + map.locations.push(DagSpacetimeLocation { + node: 0, + qubits: vec![QubitId(0)], + before: false, + gate_type: GateType::H, + idle_duration: 0, + }); + map.dem_output_metadata = vec![ + DemOutputMetadata::tracked_pauli(pecos_core::PauliString::xs(&[0])), + DemOutputMetadata::observable(pecos_core::PauliString::zs(&[0])), + DemOutputMetadata::tracked_pauli(pecos_core::PauliString::zs(&[1])), + ]; + + map.influences.dem_outputs_x.extend([0, 1, 2]); + map.influences.dem_outputs_x.finish_row(); + map.influences.dem_outputs_y.finish_row(); + map.influences.dem_outputs_z.finish_row(); + map.influences.detectors_x.finish_row(); + map.influences.detectors_y.finish_row(); + map.influences.detectors_z.finish_row(); + map.influences.num_locations = 1; + + assert_eq!(map.num_dem_outputs(), 1); + assert_eq!(map.num_tracked_paulis(), 2); + assert_eq!(map.get_observable_indices(0, Pauli::X.as_u8()), vec![0]); + assert_eq!( + map.get_tracked_pauli_indices(0, Pauli::X.as_u8()), + vec![0, 1] + ); + + let ( + _num_locations, + _num_detectors, + num_dem_outputs, + _detector_offsets_x, + _detector_data_x, + _detector_offsets_y, + _detector_data_y, + _detector_offsets_z, + _detector_data_z, + dem_output_offsets_x, + dem_output_data_x, + _dem_output_offsets_y, + _dem_output_data_y, + _dem_output_offsets_z, + _dem_output_data_z, + ) = map.export_csr(); + + assert_eq!(num_dem_outputs, 1); + assert_eq!(dem_output_offsets_x, vec![0, 1]); + assert_eq!(dem_output_data_x, vec![0]); + } + // ========================================================================= // Per-Qubit Fault Location Tests // ========================================================================= @@ -1460,11 +2837,11 @@ mod tests { .filter(|loc| matches!(loc.gate_type, GateType::CX)) .collect(); - // Should have 4 locations: before/after for each of 2 qubits + // Should have 2 locations: one per qubit (after only) assert_eq!( cx_locations.len(), - 4, - "CX should have 4 fault locations (before/after x 2 qubits)" + 2, + "CX should have 2 fault locations (1 per qubit, after gate)" ); // Each location should have exactly 1 qubit (per-qubit fault model) @@ -1509,31 +2886,31 @@ mod tests { #[test] fn test_per_qubit_fault_influences() { - // Test that per-qubit fault locations correctly track influences + // Test that per-qubit fault locations correctly track influences. + // In the standard model, faults are AFTER unitary gates. + // X on the TARGET after CX(0, 2) flips the Z-measurement on qubit 2. let mut dag = DagCircuit::new(); dag.pz(&[2]); // ancilla - dag.cx(&[(0, 2)]); // X on control spreads to target + dag.cx(&[(0, 2)]); // X on target flips measurement dag.mz(&[2]); let analyzer = DagFaultAnalyzer::new(&dag); let map = analyzer.build_influence_map(); - // X error on data qubit 0 (control of CX) should flip the Z-measurement - // because X on control stays on control, but also spreads to target - let mut found_data_qubit_influence = false; + // X error on target qubit 2 after CX should flip the measurement + let mut found_target_influence = false; for (loc_idx, loc) in map.locations.iter().enumerate() { - // Check data qubit 0 locations - if loc.qubits.iter().any(|q| q.index() == 0) { - // X fault (pauli=1) should have detector flips - if map.influences.has_detector_flips(loc_idx, Pauli::X) { - found_data_qubit_influence = true; - } + if loc.qubits.iter().any(|q| q.index() == 2) + && matches!(loc.gate_type, GateType::CX) + && map.influences.has_detector_flips(loc_idx, Pauli::X) + { + found_target_influence = true; } } assert!( - found_data_qubit_influence, - "X error on data qubit should influence measurement" + found_target_influence, + "X error on target qubit after CX should influence measurement" ); } diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/pauli.rs b/crates/pecos-qec/src/fault_tolerance/propagator/pauli.rs index d81932a7c..5b5df18d3 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/pauli.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/pauli.rs @@ -109,6 +109,18 @@ fn apply_named_gate( GateType::H => { prop.h(qubits); } + GateType::F => { + match direction { + Direction::Forward => prop.f(qubits), + Direction::Backward => prop.fdg(qubits), + }; + } + GateType::Fdg => { + match direction { + Direction::Forward => prop.fdg(qubits), + Direction::Backward => prop.f(qubits), + }; + } // Non-self-adjoint single-qubit gates - swap with adjoint for backward GateType::SX => { @@ -150,24 +162,36 @@ fn apply_named_gate( // Self-adjoint two-qubit gates - same in both directions GateType::CX => { - if qubits.len() >= 2 { - prop.cx(&[(qubits[0], qubits[1])]); - } + let pairs: Vec<_> = qubits + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| (c[0], c[1])) + .collect(); + prop.cx(&pairs); } GateType::CY => { - if qubits.len() >= 2 { - prop.cy(&[(qubits[0], qubits[1])]); - } + let pairs: Vec<_> = qubits + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| (c[0], c[1])) + .collect(); + prop.cy(&pairs); } GateType::CZ => { - if qubits.len() >= 2 { - prop.cz(&[(qubits[0], qubits[1])]); - } + let pairs: Vec<_> = qubits + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| (c[0], c[1])) + .collect(); + prop.cz(&pairs); } GateType::SWAP => { - if qubits.len() >= 2 { - prop.swap(&[(qubits[0], qubits[1])]); - } + let pairs: Vec<_> = qubits + .chunks(2) + .filter(|c| c.len() == 2) + .map(|c| (c[0], c[1])) + .collect(); + prop.swap(&pairs); } // Non-self-adjoint two-qubit Clifford gates - swap with adjoint for backward @@ -240,15 +264,15 @@ pub fn propagate_through_circuit( match direction { Direction::Forward => { for tick in circuit.ticks() { - for gate in tick.gates() { - apply_gate(prop, gate, direction); + for gate in tick.iter_gate_batches() { + apply_gate(prop, gate.as_gate(), direction); } } } Direction::Backward => { for tick in circuit.ticks().iter().rev() { - for gate in tick.gates() { - apply_gate(prop, gate, direction); + for gate in tick.iter_gate_batches() { + apply_gate(prop, gate.as_gate(), direction); } } } @@ -281,16 +305,16 @@ pub fn propagate_tick_range( Direction::Forward => { for tick_idx in start..=end { let tick = &circuit.ticks()[tick_idx]; - for gate in tick.gates() { - apply_gate(prop, gate, direction); + for gate in tick.iter_gate_batches() { + apply_gate(prop, gate.as_gate(), direction); } } } Direction::Backward => { for tick_idx in (start..=end).rev() { let tick = &circuit.ticks()[tick_idx]; - for gate in tick.gates() { - apply_gate(prop, gate, direction); + for gate in tick.iter_gate_batches() { + apply_gate(prop, gate.as_gate(), direction); } } } diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/tick.rs b/crates/pecos-qec/src/fault_tolerance/propagator/tick.rs index e3513c64f..7e75427b5 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/tick.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/tick.rs @@ -18,7 +18,7 @@ //! For better performance, consider using [`DagFaultAnalyzer`](super::DagFaultAnalyzer) //! with DAG circuits, which provides 5-50x speedup through sparse traversal. -use super::types::{DetectorId, FaultInfluence, FaultInfluenceMap, LogicalId, MeasurementId}; +use super::types::{DetectorId, FaultInfluence, FaultInfluenceMap, MeasurementId, TrackedPauliId}; use super::{Direction, SpacetimeLocation, apply_gate, extract_spacetime_locations}; use pecos_core::gate_type::GateType; use pecos_quantum::TickCircuit; @@ -64,7 +64,7 @@ impl<'a> TickFaultAnalyzer<'a> { // Find max qubit for active qubit tracking let mut max_qubit = 0; for tick in circuit.ticks() { - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { for qubit in &gate.qubits { max_qubit = max_qubit.max(qubit.index()); } @@ -85,20 +85,20 @@ impl<'a> TickFaultAnalyzer<'a> { /// creates a lookup table for fault classification. #[must_use] pub fn build_influence_map(&self) -> FaultInfluenceMap { - self.build_influence_map_with_logicals(&[]) + self.build_influence_map_with_tracked_paulis(&[]) } - /// Builds the fault influence map with logical operator tracking. + /// Builds the fault influence map with tracked Pauli tracking. /// /// # Arguments /// - /// * `logicals` - Logical operators as (`x_positions`, `z_positions`) pairs. + /// * `tracked_paulis` - Tracked Paulis as (`x_positions`, `z_positions`) pairs. /// The first element of each pair is the X component positions, /// the second is the Z component positions. #[must_use] - pub fn build_influence_map_with_logicals( + pub fn build_influence_map_with_tracked_paulis( &self, - logicals: &[(&[usize], &[usize])], + tracked_paulis: &[(&[usize], &[usize])], ) -> FaultInfluenceMap { let mut map = FaultInfluenceMap::new(); @@ -112,11 +112,11 @@ impl<'a> TickFaultAnalyzer<'a> { map.detectors.push(DetectorId::single(*m)); } - // Create logical IDs - for (i, _) in logicals.iter().enumerate() { - map.logicals.push(LogicalId { - logical_qubit: i, - observable: 0, // Z observable + // Create tracked-Pauli IDs + for (i, _) in tracked_paulis.iter().enumerate() { + map.tracked_paulis.push(TrackedPauliId { + op_index: i, + component: 0, }); } @@ -131,13 +131,13 @@ impl<'a> TickFaultAnalyzer<'a> { self.propagate_from_measurement(measurement, &mut map); } - // Backward propagate from each logical operator - for (i, (x_pos, z_pos)) in logicals.iter().enumerate() { - let logical_id = LogicalId { - logical_qubit: i, - observable: 0, + // Backward propagate from each tracked Pauli + for (i, (x_pos, z_pos)) in tracked_paulis.iter().enumerate() { + let tracked_pauli_id = TrackedPauliId { + op_index: i, + component: 0, }; - self.propagate_from_logical(x_pos, z_pos, &logical_id, &mut map); + self.propagate_from_tracked_pauli(x_pos, z_pos, &tracked_pauli_id, &mut map); } // Build reverse maps @@ -151,7 +151,7 @@ impl<'a> TickFaultAnalyzer<'a> { let mut measurements = Vec::new(); for (tick_idx, tick) in self.circuit.iter_ticks() { - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { // Currently only Z-basis measurements are supported let basis = match gate.gate_type { GateType::MZ | GateType::MeasureFree => 0, // Z-basis @@ -229,7 +229,7 @@ impl<'a> TickFaultAnalyzer<'a> { // Apply gates at this tick backward - SPARSE: only gates touching active qubits if tick_idx < self.circuit.ticks().len() { let tick = &self.circuit.ticks()[tick_idx]; - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { // Check if this gate touches any active qubit let touches_active = gate.qubits.iter().any(|q| { let idx = q.index(); @@ -238,7 +238,7 @@ impl<'a> TickFaultAnalyzer<'a> { if touches_active { // Apply gate backward - Self::apply_gate_backward(&mut prop, gate); + Self::apply_gate_backward(&mut prop, gate.as_gate()); // Update active qubits based on new Pauli state for q in &gate.qubits { @@ -259,37 +259,37 @@ impl<'a> TickFaultAnalyzer<'a> { } } - /// Propagates backward from a logical operator. + /// Propagates backward from a tracked Pauli. /// - /// We propagate the logical OBSERVABLE backward through the circuit. - /// An error P at location L flips the logical if P anticommutes with - /// the back-propagated observable at L. + /// We propagate the tracked Pauli backward through the circuit. An error + /// P at location L flips it if P anticommutes with the back-propagated + /// operator at L. /// /// This uses sparse traversal: only gates touching qubits with non-trivial /// Paulis are processed, providing significant speedup for circuits with /// local connectivity. - fn propagate_from_logical( + fn propagate_from_tracked_pauli( &self, x_positions: &[usize], z_positions: &[usize], - logical_id: &LogicalId, + tracked_pauli_id: &TrackedPauliId, map: &mut FaultInfluenceMap, ) { - // Start with the logical observable itself (not swapped) + // Start with the tracked Pauli itself (not swapped) // The recording function handles anticommutation checking let mut prop = PauliProp::new(); // Track active qubits for sparse traversal let mut active_qubits = vec![false; self.max_qubit + 1]; - // X positions in logical -> X in prop + // X positions in tracked Pauli -> X in prop for &q in x_positions { prop.track_x(&[q]); if q <= self.max_qubit { active_qubits[q] = true; } } - // Z positions in logical -> Z in prop + // Z positions in tracked Pauli -> Z in prop for &q in z_positions { prop.track_z(&[q]); if q <= self.max_qubit { @@ -312,14 +312,14 @@ impl<'a> TickFaultAnalyzer<'a> { tick_idx, &prop, &dummy_detector, - Some(logical_id), + Some(tracked_pauli_id), map, false, ); // Apply gates backward - SPARSE: only gates touching active qubits let tick = &self.circuit.ticks()[tick_idx]; - for gate in tick.gates() { + for gate in tick.iter_gate_batches() { // Check if this gate touches any active qubit let touches_active = gate.qubits.iter().any(|q| { let idx = q.index(); @@ -328,7 +328,7 @@ impl<'a> TickFaultAnalyzer<'a> { if touches_active { // Apply gate backward - Self::apply_gate_backward(&mut prop, gate); + Self::apply_gate_backward(&mut prop, gate.as_gate()); // Update active qubits based on new Pauli state for q in &gate.qubits { @@ -345,7 +345,7 @@ impl<'a> TickFaultAnalyzer<'a> { tick_idx, &prop, &dummy_detector, - Some(logical_id), + Some(tracked_pauli_id), map, true, ); @@ -368,7 +368,7 @@ impl<'a> TickFaultAnalyzer<'a> { tick_idx: usize, prop: &PauliProp, detector: &DetectorId, - logical: Option<&LogicalId>, + tracked_pauli: Option<&TrackedPauliId>, map: &mut FaultInfluenceMap, only_before: bool, ) { @@ -400,8 +400,8 @@ impl<'a> TickFaultAnalyzer<'a> { // X fault anticommutes with Z or Y observable let x_flips = obs_z; // Z or Y (both have Z component) if x_flips { - if let Some(log) = logical { - influence.logical_flips[1].push(*log); + if let Some(op) = tracked_pauli { + influence.tracked_pauli_flips[1].push(*op); } else { influence.detector_flips[1].push(detector.clone()); influence.measurement_flips[1] @@ -419,8 +419,8 @@ impl<'a> TickFaultAnalyzer<'a> { // (Z anticommutes with X, Z anticommutes with Y=iXZ) let z_flips = obs_x; // X or Y (both have X component) if z_flips { - if let Some(log) = logical { - influence.logical_flips[3].push(*log); + if let Some(op) = tracked_pauli { + influence.tracked_pauli_flips[3].push(*op); } else { influence.detector_flips[3].push(detector.clone()); influence.measurement_flips[3] @@ -434,12 +434,11 @@ impl<'a> TickFaultAnalyzer<'a> { } } - // Y fault = iXZ: Y anticommutes with X, Z, and Y - // Y anticommutes with observable if observable has X or Z component - let y_flips = obs_x || obs_z; + // Y fault: Y anticommutes with X or Z but NOT both (Y commutes with Y) + let y_flips = obs_x ^ obs_z; if y_flips { - if let Some(log) = logical { - influence.logical_flips[2].push(*log); + if let Some(op) = tracked_pauli { + influence.tracked_pauli_flips[2].push(*op); } else { influence.detector_flips[2].push(detector.clone()); influence.measurement_flips[2] @@ -486,7 +485,7 @@ impl<'a> TickFaultAnalyzer<'a> { apply_gate(prop, gate, Direction::Backward); } - /// Builds reverse maps (detector -> faults, logical -> faults). + /// Builds reverse maps (detector -> faults, tracked Pauli -> faults). fn build_reverse_maps(map: &mut FaultInfluenceMap) { for (loc, influence) in &map.influences { for (pauli, detectors) in influence.detector_flips.iter().enumerate() { @@ -500,12 +499,12 @@ impl<'a> TickFaultAnalyzer<'a> { } } - for (pauli, logicals) in influence.logical_flips.iter().enumerate() { + for (pauli, tracked_paulis) in influence.tracked_pauli_flips.iter().enumerate() { #[allow(clippy::cast_possible_truncation)] // Pauli index 0..2 let pauli_u8 = pauli as u8; - for logical in logicals { - map.logical_to_faults - .entry(*logical) + for tracked_pauli in tracked_paulis { + map.tracked_pauli_to_faults + .entry(*tracked_pauli) .or_default() .push((loc.clone(), pauli_u8)); } diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/tick_soa.rs b/crates/pecos-qec/src/fault_tolerance/propagator/tick_batched.rs similarity index 60% rename from crates/pecos-qec/src/fault_tolerance/propagator/tick_soa.rs rename to crates/pecos-qec/src/fault_tolerance/propagator/tick_batched.rs index 4fbabc728..388a53ec8 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/tick_soa.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/tick_batched.rs @@ -1,21 +1,21 @@ -//! Optimized DOD-based tick fault analyzer using `TickCircuitSoA`. +//! Optimized tick fault analyzer using batched `TickCircuit` access. //! -//! This module provides [`TickFaultAnalyzerSoA`] which leverages the Structure of Arrays -//! layout of [`TickCircuitSoA`] for more cache-efficient fault analysis. +//! This module provides [`TickFaultAnalyzerBatched`] which uses the batched +//! full-fidelity command view of [`TickCircuit`] for cache-efficient fault +//! analysis without requiring a converted circuit representation. //! //! # Optimizations //! -//! 1. **Raw index access**: Uses u32 indices instead of `GateId` validation +//! 1. **Raw index access**: Uses local flattened gate indices //! 2. **Bitset for visited tracking**: O(1) membership check instead of `Vec::contains` //! 3. **Pre-computed tick indexes**: O(1) lookup for gates in each tick -//! 4. **Sorted qubit gates**: Gates per qubit sorted by tick for efficient backward traversal -//! 5. **Direct array access**: Skips Option-returning methods in hot loops +//! 4. **Direct array access**: Skips Option-returning methods in hot loops use super::SpacetimeLocation; -use super::types::{DetectorId, FaultInfluence, FaultInfluenceMap, LogicalId, MeasurementId}; -use pecos_core::gate_type::GateType; -use pecos_quantum::tick_circuit_soa::TickCircuitSoA; -use pecos_simulators::PauliProp; +use super::types::{DetectorId, FaultInfluence, FaultInfluenceMap, MeasurementId, TrackedPauliId}; +use pecos_core::{QubitId, gate_type::GateType}; +use pecos_quantum::TickCircuit; +use pecos_simulators::{CliffordGateable, PauliProp}; // ============================================================================ // Work Buffers for Reuse @@ -29,7 +29,7 @@ pub struct AnalyzerWorkBuffers { /// Bitset for tracking processed gates in current tick processed_gates: Vec, /// Temporary storage for gates to process - gates_to_process: Vec, + gates_to_process: Vec, } impl AnalyzerWorkBuffers { @@ -62,25 +62,39 @@ impl AnalyzerWorkBuffers { } // ============================================================================ -// Optimized SOA-Based Fault Analyzer +// Optimized Batched Tick Fault Analyzer // ============================================================================ -/// Optimized fault analyzer for `TickCircuitSoA`. +#[derive(Debug, Clone)] +struct AnalyzerGate { + tick: usize, + gate_type: GateType, + qubits: Vec, +} + +/// Optimized fault analyzer for `TickCircuit`. /// /// Uses raw indices and bitsets for minimal overhead in hot paths. -pub struct TickFaultAnalyzerSoA<'a> { - circuit: &'a TickCircuitSoA, +pub struct TickFaultAnalyzerBatched<'a> { + circuit: &'a TickCircuit, + /// Flattened full-fidelity gate command view. + gates: Vec, + /// Pre-computed index: tick -> gate indices + tick_gates: Vec>, + /// Maximum qubit index seen. + max_qubit: usize, /// Fault locations extracted from the circuit. locations: Vec, /// Pre-computed index: tick -> (`location_index`, before) pairs tick_locations: Vec>, } -impl<'a> TickFaultAnalyzerSoA<'a> { - /// Creates a new analyzer for the given `SoA` circuit. +impl<'a> TickFaultAnalyzerBatched<'a> { + /// Creates a new analyzer for the given circuit. #[must_use] - pub fn new(circuit: &'a TickCircuitSoA) -> Self { - let locations = Self::extract_spacetime_locations(circuit); + pub fn new(circuit: &'a TickCircuit) -> Self { + let (gates, tick_gates, max_qubit) = Self::flatten_circuit(circuit); + let locations = Self::extract_spacetime_locations(&gates); // Build tick index for O(1) lookup let num_ticks = circuit.num_ticks(); @@ -93,41 +107,60 @@ impl<'a> TickFaultAnalyzerSoA<'a> { Self { circuit, + gates, + tick_gates, + max_qubit, locations, tick_locations, } } - /// Extracts spacetime locations using raw index access. - fn extract_spacetime_locations(circuit: &TickCircuitSoA) -> Vec { - let mut locations = Vec::new(); - let storage = &circuit.storage; + fn flatten_circuit(circuit: &TickCircuit) -> (Vec, Vec>, usize) { + let mut gates = Vec::new(); + let mut tick_gates = vec![Vec::new(); circuit.num_ticks()]; + let mut max_qubit = 0usize; - for idx in 0..storage.slot_count() { - if !storage.is_occupied(idx) { - continue; + for (tick, gate) in circuit.iter_gate_batches_with_tick() { + let idx = gates.len(); + let qubits = gate.qubits.to_vec(); + for qubit in &qubits { + max_qubit = max_qubit.max(qubit.index()); + } + if tick >= tick_gates.len() { + tick_gates.resize_with(tick + 1, Vec::new); } + tick_gates[tick].push(idx); + gates.push(AnalyzerGate { + tick, + gate_type: gate.gate_type, + qubits, + }); + } - let gate_type = storage.type_unchecked(idx); - let qubits = storage.qubits_unchecked(idx).to_vec(); - let tick = storage.tick_id_unchecked(idx) as usize; + (gates, tick_gates, max_qubit) + } + + /// Extracts spacetime locations using raw index access. + fn extract_spacetime_locations(gates: &[AnalyzerGate]) -> Vec { + let mut locations = Vec::new(); + for (idx, gate) in gates.iter().enumerate() { // Before location (fault before gate) locations.push(SpacetimeLocation { - tick, - qubits: qubits.clone(), + tick: gate.tick, + qubits: gate.qubits.clone(), before: true, - gate_type, + gate_type: gate.gate_type, gate_index: idx, }); // After location for most gates (except prep which resets) - if !matches!(gate_type, GateType::PZ | GateType::QAlloc) { + if !matches!(gate.gate_type, GateType::PZ | GateType::QAlloc) { locations.push(SpacetimeLocation { - tick, - qubits, + tick: gate.tick, + qubits: gate.qubits.clone(), before: false, - gate_type, + gate_type: gate.gate_type, gate_index: idx, }); } @@ -145,14 +178,14 @@ impl<'a> TickFaultAnalyzerSoA<'a> { /// Builds the complete fault influence map. #[must_use] pub fn build_influence_map(&self) -> FaultInfluenceMap { - self.build_influence_map_with_logicals(&[]) + self.build_influence_map_with_tracked_paulis(&[]) } - /// Builds the fault influence map with logical operator tracking. + /// Builds the fault influence map with tracked Pauli tracking. #[must_use] - pub fn build_influence_map_with_logicals( + pub fn build_influence_map_with_tracked_paulis( &self, - logicals: &[(&[usize], &[usize])], + tracked_paulis: &[(&[usize], &[usize])], ) -> FaultInfluenceMap { let mut map = FaultInfluenceMap::new(); @@ -165,11 +198,11 @@ impl<'a> TickFaultAnalyzerSoA<'a> { map.detectors.push(DetectorId::single(*m)); } - // Create logical IDs - for (i, _) in logicals.iter().enumerate() { - map.logicals.push(LogicalId { - logical_qubit: i, - observable: 0, + // Create tracked-Pauli IDs + for (i, _) in tracked_paulis.iter().enumerate() { + map.tracked_paulis.push(TrackedPauliId { + op_index: i, + component: 0, }); } @@ -180,8 +213,8 @@ impl<'a> TickFaultAnalyzerSoA<'a> { } // Create work buffers - let max_qubit = self.circuit.max_qubit(); - let max_gate = self.circuit.storage.slot_count(); + let max_qubit = self.max_qubit; + let max_gate = self.gates.len(); let mut buffers = AnalyzerWorkBuffers::new(max_qubit, max_gate); // Backward propagate from each measurement @@ -189,16 +222,16 @@ impl<'a> TickFaultAnalyzerSoA<'a> { self.propagate_from_measurement_optimized(measurement, &mut map, &mut buffers); } - // Backward propagate from each logical operator - for (i, (x_pos, z_pos)) in logicals.iter().enumerate() { - let logical_id = LogicalId { - logical_qubit: i, - observable: 0, + // Backward propagate from each tracked Pauli + for (i, (x_pos, z_pos)) in tracked_paulis.iter().enumerate() { + let tracked_pauli_id = TrackedPauliId { + op_index: i, + component: 0, }; - self.propagate_from_logical_optimized( + self.propagate_from_tracked_pauli_optimized( x_pos, z_pos, - &logical_id, + &tracked_pauli_id, &mut map, &mut buffers, ); @@ -213,25 +246,16 @@ impl<'a> TickFaultAnalyzerSoA<'a> { /// Extracts all measurements using raw index access. fn extract_measurements(&self) -> Vec { let mut measurements = Vec::new(); - let storage = &self.circuit.storage; - - for idx in 0..storage.slot_count() { - if !storage.is_occupied(idx) { - continue; - } - let gate_type = storage.type_unchecked(idx); - let basis = match gate_type { + for gate in &self.gates { + let basis = match gate.gate_type { GateType::MZ | GateType::MeasureFree => 0, // Z-basis _ => continue, }; - let tick = storage.tick_id_unchecked(idx) as usize; - let qubits = storage.qubits_unchecked(idx); - - for qubit in qubits { + for qubit in &gate.qubits { measurements.push(MeasurementId { - tick, + tick: gate.tick, qubit: qubit.index(), basis, }); @@ -297,25 +321,21 @@ impl<'a> TickFaultAnalyzerSoA<'a> { prop: &mut PauliProp, buffers: &mut AnalyzerWorkBuffers, ) { - let storage = &self.circuit.storage; - // Clear processed gates bitset for this tick buffers.gates_to_process.clear(); // Get gates in this tick directly from pre-computed index - let tick_gates = self.circuit.gates_in_tick_raw(tick_idx); + let tick_gates = self + .tick_gates + .get(tick_idx) + .map_or([].as_slice(), Vec::as_slice); // Find gates that touch active qubits for &gate_idx in tick_gates { - let idx = gate_idx as usize; - if !storage.is_occupied(idx) { - continue; - } - - let qubits = storage.qubits_unchecked(idx); + let gate = &self.gates[gate_idx]; // Check if any qubit is active - let touches_active = qubits.iter().any(|q| { + let touches_active = gate.qubits.iter().any(|q| { let qi = q.index(); qi < buffers.active_qubits.len() && buffers.active_qubits[qi] }); @@ -329,7 +349,7 @@ impl<'a> TickFaultAnalyzerSoA<'a> { // Note: We iterate by index to avoid borrow conflict let num_gates = buffers.gates_to_process.len(); for i in 0..num_gates { - let gate_idx = buffers.gates_to_process[i] as usize; + let gate_idx = buffers.gates_to_process[i]; self.apply_gate_backward_raw(gate_idx, prop, buffers); } } @@ -342,50 +362,100 @@ impl<'a> TickFaultAnalyzerSoA<'a> { prop: &mut PauliProp, buffers: &mut AnalyzerWorkBuffers, ) { - let storage = &self.circuit.storage; - let gate_type = storage.type_unchecked(idx); - let qubits = storage.qubits_unchecked(idx); + let gate = &self.gates[idx]; + let gate_type = gate.gate_type; + let qubits = gate.qubits.as_slice(); match gate_type { GateType::CX if qubits.len() >= 2 => { - let control = qubits[0].index(); - let target = qubits[1].index(); + for pair in qubits.chunks_exact(2) { + let control = pair[0].index(); + let target = pair[1].index(); - let ctrl_x = prop.contains_x(control); - let tgt_z = prop.contains_z(target); + let ctrl_x = prop.contains_x(control); + let tgt_z = prop.contains_z(target); - if ctrl_x { - prop.track_x(&[target]); - } - if tgt_z { - prop.track_z(&[control]); - } + if ctrl_x { + prop.track_x(&[target]); + } + if tgt_z { + prop.track_z(&[control]); + } - // Update active qubits - Self::update_active_qubit(control, prop, buffers); - Self::update_active_qubit(target, prop, buffers); + // Update active qubits + Self::update_active_qubit(control, prop, buffers); + Self::update_active_qubit(target, prop, buffers); + } } GateType::CZ if qubits.len() >= 2 => { - let q0 = qubits[0].index(); - let q1 = qubits[1].index(); + for pair in qubits.chunks_exact(2) { + let q0 = pair[0].index(); + let q1 = pair[1].index(); - let x0 = prop.contains_x(q0); - let x1 = prop.contains_x(q1); + let x0 = prop.contains_x(q0); + let x1 = prop.contains_x(q1); - if x0 { - prop.track_z(&[q1]); - } - if x1 { - prop.track_z(&[q0]); + if x0 { + prop.track_z(&[q1]); + } + if x1 { + prop.track_z(&[q0]); + } + + Self::update_active_qubit(q0, prop, buffers); + Self::update_active_qubit(q1, prop, buffers); } + } - Self::update_active_qubit(q0, prop, buffers); - Self::update_active_qubit(q1, prop, buffers); + GateType::CY + | GateType::SWAP + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg + | GateType::SZZ + | GateType::SZZdg + if qubits.len() >= 2 => + { + for pair in qubits.chunks_exact(2) { + let q0 = pair[0]; + let q1 = pair[1]; + let pair = [(q0, q1)]; + match gate_type { + GateType::CY => { + prop.cy(&pair); + } + GateType::SWAP => { + prop.swap(&pair); + } + GateType::SXX => { + prop.sxxdg(&pair); + } + GateType::SXXdg => { + prop.sxx(&pair); + } + GateType::SYY => { + prop.syydg(&pair); + } + GateType::SYYdg => { + prop.syy(&pair); + } + GateType::SZZ => { + prop.szzdg(&pair); + } + GateType::SZZdg => { + prop.szz(&pair); + } + _ => unreachable!(), + } + Self::update_active_qubit(q0.index(), prop, buffers); + Self::update_active_qubit(q1.index(), prop, buffers); + } } GateType::H => { - if let Some(qid) = qubits.first() { + for qid in qubits { let q = qid.index(); let has_x = prop.contains_x(q); let has_z = prop.contains_z(q); @@ -402,8 +472,41 @@ impl<'a> TickFaultAnalyzerSoA<'a> { } } + GateType::SX + | GateType::SXdg + | GateType::SY + | GateType::SYdg + | GateType::F + | GateType::Fdg => { + for qid in qubits { + let q = [QubitId(qid.index())]; + match gate_type { + GateType::SX => { + prop.sxdg(&q); + } + GateType::SXdg => { + prop.sx(&q); + } + GateType::SY => { + prop.sydg(&q); + } + GateType::SYdg => { + prop.sy(&q); + } + GateType::F => { + prop.fdg(&q); + } + GateType::Fdg => { + prop.f(&q); + } + _ => unreachable!(), + } + Self::update_active_qubit(qid.index(), prop, buffers); + } + } + GateType::SZ | GateType::SZdg => { - if let Some(qid) = qubits.first() { + for qid in qubits { let q = qid.index(); let has_x = prop.contains_x(q); @@ -444,12 +547,12 @@ impl<'a> TickFaultAnalyzerSoA<'a> { } } - /// Optimized backward propagation from a logical operator. - fn propagate_from_logical_optimized( + /// Optimized backward propagation from a tracked Pauli. + fn propagate_from_tracked_pauli_optimized( &self, x_positions: &[usize], z_positions: &[usize], - logical_id: &LogicalId, + tracked_pauli_id: &TrackedPauliId, map: &mut FaultInfluenceMap, buffers: &mut AnalyzerWorkBuffers, ) { @@ -482,7 +585,7 @@ impl<'a> TickFaultAnalyzerSoA<'a> { tick_idx, &prop, &dummy_detector, - Some(logical_id), + Some(tracked_pauli_id), map, false, ); @@ -493,7 +596,7 @@ impl<'a> TickFaultAnalyzerSoA<'a> { tick_idx, &prop, &dummy_detector, - Some(logical_id), + Some(tracked_pauli_id), map, true, ); @@ -507,7 +610,7 @@ impl<'a> TickFaultAnalyzerSoA<'a> { tick_idx: usize, prop: &PauliProp, detector: &DetectorId, - logical: Option<&LogicalId>, + tracked_pauli: Option<&TrackedPauliId>, map: &mut FaultInfluenceMap, only_before: bool, ) { @@ -531,8 +634,8 @@ impl<'a> TickFaultAnalyzerSoA<'a> { if let Some(influence) = map.influences.get_mut(loc) { // X fault anticommutes with Z or Y observable if obs_z { - if let Some(log) = logical { - influence.logical_flips[1].push(*log); + if let Some(op) = tracked_pauli { + influence.tracked_pauli_flips[1].push(*op); } else { influence.detector_flips[1].push(detector.clone()); influence.measurement_flips[1] @@ -547,8 +650,8 @@ impl<'a> TickFaultAnalyzerSoA<'a> { // Z fault anticommutes with X or Y observable if obs_x { - if let Some(log) = logical { - influence.logical_flips[3].push(*log); + if let Some(op) = tracked_pauli { + influence.tracked_pauli_flips[3].push(*op); } else { influence.detector_flips[3].push(detector.clone()); influence.measurement_flips[3] @@ -561,10 +664,10 @@ impl<'a> TickFaultAnalyzerSoA<'a> { } } - // Y fault anticommutes with any non-identity observable - if obs_x || obs_z { - if let Some(log) = logical { - influence.logical_flips[2].push(*log); + // Y fault: anticommutes with X or Z but NOT both (Y commutes with Y) + if obs_x ^ obs_z { + if let Some(op) = tracked_pauli { + influence.tracked_pauli_flips[2].push(*op); } else { influence.detector_flips[2].push(detector.clone()); influence.measurement_flips[2] @@ -595,12 +698,12 @@ impl<'a> TickFaultAnalyzerSoA<'a> { } } - for (pauli, logicals) in influence.logical_flips.iter().enumerate() { + for (pauli, tracked_paulis) in influence.tracked_pauli_flips.iter().enumerate() { #[allow(clippy::cast_possible_truncation)] // Pauli index 0..2 let pauli_u8 = pauli as u8; - for logical in logicals { - map.logical_to_faults - .entry(*logical) + for tracked_pauli in tracked_paulis { + map.tracked_pauli_to_faults + .entry(*tracked_pauli) .or_default() .push((loc.clone(), pauli_u8)); } @@ -612,23 +715,16 @@ impl<'a> TickFaultAnalyzerSoA<'a> { #[cfg(test)] mod tests { use super::*; - use pecos_quantum::tick_circuit_soa::TickCircuitSoABuilder; + use pecos_quantum::TickCircuit; #[test] fn test_basic_analysis() { - let mut builder = TickCircuitSoABuilder::new(); - builder - .tick() - .pz(&[0, 1]) - .tick() - .h(&[0]) - .tick() - .cx(&[(0, 1)]) - .tick() - .mz(&[0, 1]); - - let circuit = builder.build(); - let analyzer = TickFaultAnalyzerSoA::new(&circuit); + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0, 1]); + circuit.tick().h(&[0]); + circuit.tick().cx(&[(0, 1)]); + circuit.tick().mz(&[0, 1]); + let analyzer = TickFaultAnalyzerBatched::new(&circuit); assert!(!analyzer.locations().is_empty()); @@ -640,21 +736,12 @@ mod tests { #[test] fn test_sparse_traversal() { - let mut builder = TickCircuitSoABuilder::new(); - builder - .tick() - .pz(&[0, 1, 2, 3]) - .tick() - .h(&[0]) - .h(&[2]) - .tick() - .cx(&[(0, 1)]) - .cx(&[(2, 3)]) - .tick() - .mz(&[0, 1, 2, 3]); - - let circuit = builder.build(); - let analyzer = TickFaultAnalyzerSoA::new(&circuit); + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0, 1, 2, 3]); + circuit.tick().h(&[0]).h(&[2]); + circuit.tick().cx(&[(0, 1)]).cx(&[(2, 3)]); + circuit.tick().mz(&[0, 1, 2, 3]); + let analyzer = TickFaultAnalyzerBatched::new(&circuit); let map = analyzer.build_influence_map(); @@ -663,24 +750,17 @@ mod tests { } #[test] - fn test_logical_propagation() { - let mut builder = TickCircuitSoABuilder::new(); - builder - .tick() - .pz(&[0, 1]) - .tick() - .h(&[0]) - .tick() - .cx(&[(0, 1)]) - .tick() - .mz(&[0, 1]); - - let circuit = builder.build(); - let analyzer = TickFaultAnalyzerSoA::new(&circuit); - - let logicals = [(&[] as &[usize], &[1usize] as &[usize])]; - let map = analyzer.build_influence_map_with_logicals(&logicals); - - assert_eq!(map.logicals.len(), 1); + fn test_tracked_pauli_propagation() { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0, 1]); + circuit.tick().h(&[0]); + circuit.tick().cx(&[(0, 1)]); + circuit.tick().mz(&[0, 1]); + let analyzer = TickFaultAnalyzerBatched::new(&circuit); + + let tracked_paulis = [(&[] as &[usize], &[1usize] as &[usize])]; + let map = analyzer.build_influence_map_with_tracked_paulis(&tracked_paulis); + + assert_eq!(map.tracked_paulis.len(), 1); } } diff --git a/crates/pecos-qec/src/fault_tolerance/propagator/types.rs b/crates/pecos-qec/src/fault_tolerance/propagator/types.rs index 0998d49b1..a7d6673c0 100644 --- a/crates/pecos-qec/src/fault_tolerance/propagator/types.rs +++ b/crates/pecos-qec/src/fault_tolerance/propagator/types.rs @@ -149,12 +149,56 @@ impl From for usize { } } -/// A logical observable index. +/// A standard DEM observable `L` output index. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] #[repr(transparent)] -pub struct LogicalIdx(pub u32); +pub struct DemOutputIdx(pub u32); -impl LogicalIdx { +/// A PECOS tracked-Pauli metadata index. +/// +/// This is intentionally separate from [`DemOutputIdx`]: tracked Paulis are +/// not standard DEM `L` targets and should not be handed to decoders as +/// logical observables. +#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash, Default)] +#[repr(transparent)] +pub struct TrackedPauliIdx(pub u32); + +impl DemOutputIdx { + #[inline] + #[must_use] + pub const fn new(index: u32) -> Self { + Self(index) + } + + #[inline] + #[must_use] + pub const fn index(self) -> usize { + self.0 as usize + } + + #[inline] + #[must_use] + #[allow(clippy::cast_possible_truncation)] // DEM output index fits in u32 + pub const fn from_usize(index: usize) -> Self { + Self(index as u32) + } +} + +impl From for DemOutputIdx { + #[inline] + fn from(index: usize) -> Self { + Self::from_usize(index) + } +} + +impl From for usize { + #[inline] + fn from(id: DemOutputIdx) -> Self { + id.index() + } +} + +impl TrackedPauliIdx { #[inline] #[must_use] pub const fn new(index: u32) -> Self { @@ -169,22 +213,22 @@ impl LogicalIdx { #[inline] #[must_use] - #[allow(clippy::cast_possible_truncation)] // logical index fits in u32 + #[allow(clippy::cast_possible_truncation)] // tracked-Pauli index fits in u32 pub const fn from_usize(index: usize) -> Self { Self(index as u32) } } -impl From for LogicalIdx { +impl From for TrackedPauliIdx { #[inline] fn from(index: usize) -> Self { Self::from_usize(index) } } -impl From for usize { +impl From for usize { #[inline] - fn from(id: LogicalIdx) -> Self { + fn from(id: TrackedPauliIdx) -> Self { id.index() } } @@ -287,13 +331,13 @@ impl DetectorId { } } -/// Unique identifier for a logical observable. +/// Unique identifier for a tracked Pauli in the older tick influence map. #[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct LogicalId { - /// Index of the logical qubit. - pub logical_qubit: usize, - /// Which observable: 0 = Z, 1 = X. - pub observable: u8, +pub struct TrackedPauliId { + /// Index of the tracked Pauli. + pub op_index: usize, + /// Optional Pauli component marker for callers that split X/Z components. + pub component: u8, } /// What a single fault location influences. @@ -305,8 +349,8 @@ pub struct FaultInfluence { /// Index 0 is unused (identity fault has no effect). pub detector_flips: [Vec; 4], - /// Which logical observables this fault flips, indexed by Pauli type. - pub logical_flips: [Vec; 4], + /// Which tracked Paulis this fault flips, indexed by Pauli type. + pub tracked_pauli_flips: [Vec; 4], /// Which raw measurements this fault flips, indexed by Pauli type. pub measurement_flips: [Vec; 4], @@ -321,7 +365,7 @@ impl FaultInfluence { #[must_use] pub fn is_trivial(&self) -> bool { self.detector_flips.iter().all(std::vec::Vec::is_empty) - && self.logical_flips.iter().all(std::vec::Vec::is_empty) + && self.tracked_pauli_flips.iter().all(std::vec::Vec::is_empty) && self.measurement_flips.iter().all(std::vec::Vec::is_empty) } @@ -334,11 +378,11 @@ impl FaultInfluence { .map_or(&[], |v| v.as_slice()) } - /// Returns all logicals flipped by a specific Pauli type. + /// Returns all tracked Paulis flipped by a specific Pauli type. #[inline] #[must_use] - pub fn logicals_for_pauli(&self, pauli: u8) -> &[LogicalId] { - self.logical_flips + pub fn tracked_paulis_for_pauli(&self, pauli: u8) -> &[TrackedPauliId] { + self.tracked_pauli_flips .get(pauli as usize) .map_or(&[], |v| v.as_slice()) } @@ -356,8 +400,8 @@ pub struct FaultInfluenceMap { /// All detectors in the circuit. pub detectors: Vec, - /// All logical observables being tracked. - pub logicals: Vec, + /// All tracked Paulis. + pub tracked_paulis: Vec, /// All measurements in the circuit. pub measurements: Vec, @@ -365,8 +409,8 @@ pub struct FaultInfluenceMap { /// Reverse map: for each detector, which fault locations flip it. pub detector_to_faults: BTreeMap>, - /// Reverse map: for each logical, which fault locations flip it. - pub logical_to_faults: BTreeMap>, + /// Reverse map: for each tracked Pauli, which fault locations flip it. + pub tracked_pauli_to_faults: BTreeMap>, } impl FaultInfluenceMap { @@ -376,10 +420,10 @@ impl FaultInfluenceMap { Self { influences: BTreeMap::new(), detectors: Vec::new(), - logicals: Vec::new(), + tracked_paulis: Vec::new(), measurements: Vec::new(), detector_to_faults: BTreeMap::new(), - logical_to_faults: BTreeMap::new(), + tracked_pauli_to_faults: BTreeMap::new(), } } @@ -391,14 +435,14 @@ impl FaultInfluenceMap { /// Quickly classifies a single-qubit fault based on pre-computed influences. /// - /// Returns (`has_syndrome`, `has_logical_error`) for the given Pauli type. + /// Returns (`has_syndrome`, `flips_tracked_pauli`) for the given Pauli type. /// For multi-qubit locations, use `classify_multi_qubit_fault` instead. #[must_use] pub fn classify_fault(&self, location: &SpacetimeLocation, pauli: u8) -> (bool, bool) { if let Some(influence) = self.influences.get(location) { let has_syndrome = !influence.detectors_for_pauli(pauli).is_empty(); - let has_logical = !influence.logicals_for_pauli(pauli).is_empty(); - (has_syndrome, has_logical) + let flips_tracked_pauli = !influence.tracked_paulis_for_pauli(pauli).is_empty(); + (has_syndrome, flips_tracked_pauli) } else { (false, false) } @@ -413,7 +457,7 @@ impl FaultInfluenceMap { /// For Y faults, we decompose Y = XZ and combine the X and Z contributions, /// since Y anticommutes with both X and Z components of the observable. /// - /// Returns (`has_syndrome`, `has_logical_error`). + /// Returns (`has_syndrome`, `flips_tracked_pauli`). #[must_use] pub fn classify_multi_qubit_fault( &self, @@ -458,11 +502,11 @@ impl FaultInfluenceMap { // Syndrome = odd number of flips for any detector let has_syndrome = detector_flip_counts.values().any(|&count| count % 2 == 1); - // For logicals, use the same approach - // (simplified: just check if any qubit flips logical, proper handling TBD) - let has_logical = !influence.logicals_for_pauli(pauli).is_empty(); + // For tracked Paulis, use the same approach + // (simplified: just check if any component flips, proper handling TBD) + let flips_tracked_pauli = !influence.tracked_paulis_for_pauli(pauli).is_empty(); - (has_syndrome, has_logical) + (has_syndrome, flips_tracked_pauli) } else { (false, false) } diff --git a/crates/pecos-qec/src/fault_tolerance/targeted_lookup_decoder.rs b/crates/pecos-qec/src/fault_tolerance/targeted_lookup_decoder.rs new file mode 100644 index 000000000..9df5a9356 --- /dev/null +++ b/crates/pecos-qec/src/fault_tolerance/targeted_lookup_decoder.rs @@ -0,0 +1,789 @@ +// 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. + +//! Targeted fault-catalog lookup decoder. +//! +//! Answers one detector syndrome at a time by searching the fault catalog, +//! instead of precomputing a full lookup table for every syndrome. +//! +//! Uses odds-space weights for efficient comparison: +//! - `base_probability = product of all (1 - p_i)` +//! - `odds_weight(alt) = alt.absolute_probability / (1 - p_i)` +//! - `configuration_probability = base_probability * product(selected odds_weights)` + +use super::fault_sampler::FaultCatalog; +use std::collections::{BTreeMap, BTreeSet, HashMap}; + +/// A flattened fault entry for index lookup. +#[derive(Clone, Debug)] +struct FaultEntry { + /// Index of the physical fault location in the catalog. + location_index: usize, + /// Detector effect as a sorted set of detector indices (XOR parity). + detector_bits: BTreeSet, + /// Observable/logical effect as a sorted set. + logical_bits: BTreeSet, + /// Odds-space weight: `absolute_probability / no_fault_probability`. + odds_weight: f64, +} + +#[derive(Clone, Copy)] +struct SearchState<'a> { + start_entry: usize, + needed: &'a BTreeSet, + used_locations: &'a BTreeSet, + logical_parity: &'a BTreeSet, + odds_product: f64, + depth: usize, +} + +/// Result of decoding a single syndrome. +#[derive(Clone, Debug)] +pub struct DecodeResult { + /// The queried syndrome. + pub syndrome: Vec, + /// Accumulated odds-space weights by logical class. + /// Multiply by `base_probability` for absolute probabilities. + pub logical_weights: BTreeMap, f64>, + /// The logical class with the highest weight. + pub best_logical: Vec, +} + +/// Targeted fault-catalog lookup decoder. +/// +/// Searches the fault catalog for explanations of a given detector syndrome, +/// accumulating odds-space weights by logical class up to `max_faults` +/// simultaneous fault locations. +pub struct TargetedLookupDecoder { + max_faults: usize, + base_prob: f64, + entries: Vec, + /// Index: `detector_bits` -> list of entry indices. + by_detector: HashMap, Vec>, +} + +impl TargetedLookupDecoder { + /// Build a decoder from a fault catalog. + #[must_use] + pub fn new(catalog: &FaultCatalog) -> Self { + let base_prob: f64 = catalog + .locations + .iter() + .map(|loc| loc.no_fault_probability) + .product(); + + let mut entries = Vec::new(); + for (loc_idx, loc) in catalog.locations.iter().enumerate() { + for alt in &loc.faults { + let odds = if loc.no_fault_probability > 0.0 { + alt.absolute_probability / loc.no_fault_probability + } else { + f64::INFINITY + }; + if odds == 0.0 { + continue; + } + entries.push(FaultEntry { + location_index: loc_idx, + detector_bits: alt.affected_detectors.iter().copied().collect(), + logical_bits: alt.affected_observables.iter().copied().collect(), + odds_weight: odds, + }); + } + } + + let mut by_detector: HashMap, Vec> = HashMap::new(); + for (i, entry) in entries.iter().enumerate() { + by_detector + .entry(entry.detector_bits.clone()) + .or_default() + .push(i); + } + + Self { + max_faults: 1, + base_prob, + entries, + by_detector, + } + } + + /// Set the maximum number of simultaneous fault locations to consider. + #[must_use] + pub fn max_faults(mut self, max_faults: usize) -> Self { + self.max_faults = max_faults; + self + } + + /// The all-no-fault probability: product of `(1 - p_i)` for all locations. + #[must_use] + pub fn base_probability(&self) -> f64 { + self.base_prob + } + + /// Decode a syndrome: find all explanations up to `max_faults` and accumulate + /// odds-space weights by logical class. + #[must_use] + pub fn decode(&self, syndrome: &[usize]) -> DecodeResult { + let target: BTreeSet = syndrome.iter().copied().collect(); + let mut logical_weights: BTreeMap, f64> = BTreeMap::new(); + + // k=0: empty syndrome -> empty logical with weight 1 + if target.is_empty() { + *logical_weights.entry(Vec::new()).or_default() += 1.0; + } + + // k=1: direct lookup + if self.max_faults >= 1 + && let Some(indices) = self.by_detector.get(&target) + { + for &i in indices { + let e = &self.entries[i]; + let logical: Vec = e.logical_bits.iter().copied().collect(); + *logical_weights.entry(logical).or_default() += e.odds_weight; + } + } + + // k=2: complement lookup + if self.max_faults >= 2 { + self.search_k2(&target, &mut logical_weights); + } + + // k>=3: recursive exact search + if self.max_faults >= 3 { + for k in 3..=self.max_faults { + self.search_generic(k, &target, &mut logical_weights); + } + } + + let best_logical = logical_weights + .iter() + .max_by(|(_, a), (_, b)| a.total_cmp(b)) + .map(|(logical, _)| logical.clone()) + .unwrap_or_default(); + + DecodeResult { + syndrome: syndrome.to_vec(), + logical_weights, + best_logical, + } + } + + /// k=2 complement lookup: for each entry `a`, compute + /// `needed_b = target XOR a.detectors`, + /// then look up entries with that detector effect. + fn search_k2(&self, target: &BTreeSet, logical_weights: &mut BTreeMap, f64>) { + for (i, a) in self.entries.iter().enumerate() { + let needed_b = xor_sets(target, &a.detector_bits); + if let Some(b_indices) = self.by_detector.get(&needed_b) { + for &j in b_indices { + if j <= i { + continue; // avoid double-counting (ordered pairs) + } + let b = &self.entries[j]; + if a.location_index == b.location_index { + continue; // same physical location + } + let logical = xor_sets(&a.logical_bits, &b.logical_bits); + let logical_vec: Vec = logical.into_iter().collect(); + *logical_weights.entry(logical_vec).or_default() += + a.odds_weight * b.odds_weight; + } + } + } + } + + /// Generic exact search for k >= 3. Recursive depth-first with location exclusion. + fn search_generic( + &self, + k: usize, + target: &BTreeSet, + logical_weights: &mut BTreeMap, f64>, + ) { + let used_locations = BTreeSet::new(); + let logical_parity = BTreeSet::new(); + let state = SearchState { + start_entry: 0, + needed: target, + used_locations: &used_locations, + logical_parity: &logical_parity, + odds_product: 1.0, + depth: 0, + }; + self.search_recursive(k, state, logical_weights); + } + + fn search_recursive( + &self, + k: usize, + state: SearchState<'_>, + logical_weights: &mut BTreeMap, f64>, + ) { + if state.depth == k { + if state.needed.is_empty() { + let logical_vec: Vec = state.logical_parity.iter().copied().collect(); + *logical_weights.entry(logical_vec).or_default() += state.odds_product; + } + return; + } + + let remaining = k - state.depth; + for i in state.start_entry..self.entries.len() { + // Check if enough entries remain + if self.entries.len() - i < remaining { + break; + } + + let entry = &self.entries[i]; + + // Skip if this location is already used + if state.used_locations.contains(&entry.location_index) { + continue; + } + + let new_needed = xor_sets(state.needed, &entry.detector_bits); + let new_logical = xor_sets(state.logical_parity, &entry.logical_bits); + let new_odds = state.odds_product * entry.odds_weight; + + let mut new_used = state.used_locations.clone(); + new_used.insert(entry.location_index); + + let next_state = SearchState { + start_entry: i + 1, + needed: &new_needed, + used_locations: &new_used, + logical_parity: &new_logical, + odds_product: new_odds, + depth: state.depth + 1, + }; + self.search_recursive(k, next_state, logical_weights); + } + } +} + +/// XOR two sorted sets (symmetric difference). +fn xor_sets(a: &BTreeSet, b: &BTreeSet) -> BTreeSet { + a.symmetric_difference(b).copied().collect() +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::fault_tolerance::fault_sampler::{ + FaultAlternative, FaultCatalog, FaultKind, FaultLocation, StochasticNoiseParams, + build_fault_catalog, + }; + use pecos_core::{QubitId, gate_type::GateType}; + use pecos_quantum::TickCircuit; + + /// Build a tiny circuit: H(0) CX(0,1) H(0) MZ(0) MZ(1) + /// with detector D0 = m0 XOR m1 and observable L0 = m1. + fn tiny_circuit() -> TickCircuit { + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(1)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("2".to_string()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String(r#"[{"records": [-2, -1]}]"#.to_string()), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String(r#"[{"records": [-1]}]"#.to_string()), + ); + tc + } + + fn tiny_catalog() -> FaultCatalog { + let tc = tiny_circuit(); + let noise = StochasticNoiseParams { + p1: 0.003, + p2: 0.01, + p_meas: 0.005, + p_prep: 0.005, + }; + build_fault_catalog(&tc, &noise).unwrap() + } + + /// Brute-force reference: enumerate all configurations up to `max_faults`, + /// accumulate odds weights by (syndrome, logical). + fn brute_force_weights( + catalog: &FaultCatalog, + max_faults: usize, + ) -> BTreeMap, BTreeMap, f64>> { + let base: f64 = catalog + .locations + .iter() + .map(|l| l.no_fault_probability) + .product(); + let mut result: BTreeMap, BTreeMap, f64>> = BTreeMap::new(); + for k in 0..=max_faults { + for event in catalog.fault_configurations(k) { + let odds = if base > 0.0 { + event.configuration_probability / base + } else { + 0.0 + }; + *result + .entry(event.affected_detectors) + .or_default() + .entry(event.affected_observables) + .or_default() += odds; + } + } + result + } + + #[test] + fn test_k0_empty_syndrome() { + let catalog = tiny_catalog(); + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(0); + let result = decoder.decode(&[]); + assert_eq!(result.best_logical, Vec::::new()); + assert!((result.logical_weights[&vec![]] - 1.0).abs() < 1e-12); + } + + #[test] + fn test_unparameterized_catalog_has_no_positive_fault_weights() { + let catalog = FaultCatalog::from_circuit(&tiny_circuit()).unwrap(); + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(2); + + let empty = decoder.decode(&[]); + assert_eq!(empty.logical_weights.len(), 1); + assert!((empty.logical_weights[&vec![]] - 1.0).abs() < 1e-12); + + let non_empty = decoder.decode(&[0]); + assert!( + non_empty.logical_weights.is_empty(), + "zero-probability structural faults must not create zero-weight classes" + ); + } + + #[test] + fn test_zero_probability_alternatives_are_ignored() { + let catalog = FaultCatalog { + locations: vec![ + FaultLocation { + tick: 0, + gate_index: 0, + gate_type: GateType::H, + qubits: vec![0], + channel: crate::fault_tolerance::fault_sampler::FaultChannel::P1, + channel_probability: 0.0, + no_fault_probability: 1.0, + num_alternatives: 1, + faults: vec![FaultAlternative { + kind: FaultKind::Pauli, + pauli: None, + affected_measurements: Vec::new(), + affected_detectors: vec![0], + affected_observables: vec![9], + affected_tracked_paulis: Vec::new(), + conditional_probability: 1.0, + absolute_probability: 0.0, + }], + }, + FaultLocation { + tick: 1, + gate_index: 0, + gate_type: GateType::MZ, + qubits: vec![0], + channel: crate::fault_tolerance::fault_sampler::FaultChannel::PMeas, + channel_probability: 0.1, + no_fault_probability: 0.9, + num_alternatives: 1, + faults: vec![FaultAlternative { + kind: FaultKind::MeasurementFlip, + pauli: None, + affected_measurements: vec![0], + affected_detectors: vec![0], + affected_observables: Vec::new(), + affected_tracked_paulis: Vec::new(), + conditional_probability: 1.0, + absolute_probability: 0.1, + }], + }, + ], + }; + + let result = TargetedLookupDecoder::new(&catalog) + .max_faults(1) + .decode(&[0]); + + assert!(!result.logical_weights.contains_key(&vec![9])); + assert_eq!(result.logical_weights.len(), 1); + assert!((result.logical_weights[&vec![]] - (0.1 / 0.9)).abs() < 1e-12); + } + + #[test] + fn test_decode_ignores_tracked_pauli_effects() { + let catalog = FaultCatalog { + locations: vec![ + FaultLocation { + tick: 0, + gate_index: 0, + gate_type: GateType::H, + qubits: vec![0], + channel: crate::fault_tolerance::fault_sampler::FaultChannel::P1, + channel_probability: 0.2, + no_fault_probability: 0.8, + num_alternatives: 1, + faults: vec![FaultAlternative { + kind: FaultKind::Pauli, + pauli: None, + affected_measurements: Vec::new(), + affected_detectors: vec![0], + affected_observables: vec![1], + affected_tracked_paulis: vec![0], + conditional_probability: 1.0, + absolute_probability: 0.2, + }], + }, + FaultLocation { + tick: 1, + gate_index: 0, + gate_type: GateType::H, + qubits: vec![1], + channel: crate::fault_tolerance::fault_sampler::FaultChannel::P1, + channel_probability: 0.1, + no_fault_probability: 0.9, + num_alternatives: 1, + faults: vec![FaultAlternative { + kind: FaultKind::Pauli, + pauli: None, + affected_measurements: Vec::new(), + affected_detectors: vec![0], + affected_observables: Vec::new(), + affected_tracked_paulis: vec![3], + conditional_probability: 1.0, + absolute_probability: 0.1, + }], + }, + ], + }; + + let result = TargetedLookupDecoder::new(&catalog) + .max_faults(1) + .decode(&[0]); + + assert_eq!(result.best_logical, vec![1]); + assert_eq!(result.logical_weights.len(), 2); + assert!((result.logical_weights[&vec![1]] - (0.2 / 0.8)).abs() < 1e-12); + assert!((result.logical_weights[&vec![]] - (0.1 / 0.9)).abs() < 1e-12); + } + + #[test] + fn test_unexplainable_syndrome_returns_empty_weights() { + let mut tc = TickCircuit::new(); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(1)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("2".into()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String(r#"[{"records":[-2]},{"records":[-1]}]"#.into()), + ); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let noise = StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + + let zero_fault_decoder = TargetedLookupDecoder::new(&catalog).max_faults(0); + let zero_fault_result = zero_fault_decoder.decode(&[0]); + assert!( + zero_fault_result.logical_weights.is_empty(), + "non-empty syndrome cannot be explained by zero faults" + ); + + let one_fault_decoder = TargetedLookupDecoder::new(&catalog).max_faults(1); + let one_fault_result = one_fault_decoder.decode(&[0, 1]); + assert!( + one_fault_result.logical_weights.is_empty(), + "syndrome [0, 1] requires two distinct measurement faults" + ); + } + + #[test] + fn test_k1_matches_brute_force() { + let catalog = tiny_catalog(); + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(1); + let bf = brute_force_weights(&catalog, 1); + + for (syndrome, bf_logicals) in &bf { + let result = decoder.decode(syndrome); + for (logical, &bf_weight) in bf_logicals { + let dec_weight = result.logical_weights.get(logical).copied().unwrap_or(0.0); + assert!( + (dec_weight - bf_weight).abs() < 1e-12, + "k=1 mismatch for syndrome={syndrome:?} logical={logical:?}: \ + decoder={dec_weight} brute_force={bf_weight}" + ); + } + } + } + + #[test] + fn test_k2_matches_brute_force() { + let catalog = tiny_catalog(); + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(2); + let bf = brute_force_weights(&catalog, 2); + + for (syndrome, bf_logicals) in &bf { + let result = decoder.decode(syndrome); + for (logical, &bf_weight) in bf_logicals { + let dec_weight = result.logical_weights.get(logical).copied().unwrap_or(0.0); + assert!( + (dec_weight - bf_weight).abs() / bf_weight.max(1e-15) < 1e-8, + "k=2 mismatch for syndrome={syndrome:?} logical={logical:?}: \ + decoder={dec_weight:.6e} brute_force={bf_weight:.6e}" + ); + } + } + } + + #[test] + fn test_new_clifford_gate_circuit_matches_brute_force() { + let mut tc = TickCircuit::new(); + tc.tick().sx(&[QubitId(0)]); + tc.tick().cy(&[(QubitId(0), QubitId(1))]); + tc.tick().sxx(&[(QubitId(0), QubitId(1))]); + tc.tick().swap(&[(QubitId(0), QubitId(1))]); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(1)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("2".into()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String( + r#"[{"records":[-2]},{"records":[-1]},{"records":[-2,-1]}]"#.into(), + ), + ); + tc.set_meta( + "observables", + pecos_quantum::Attribute::String(r#"[{"records":[-1]}]"#.into()), + ); + + let noise = StochasticNoiseParams { + p1: 0.003, + p2: 0.01, + p_meas: 0.0, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + assert!( + catalog + .locations + .iter() + .any(|loc| loc.gate_type == GateType::SX) + ); + assert!( + catalog + .locations + .iter() + .any(|loc| loc.gate_type == GateType::CY) + ); + assert!( + catalog + .locations + .iter() + .any(|loc| loc.gate_type == GateType::SXX) + ); + assert!( + catalog + .locations + .iter() + .any(|loc| loc.gate_type == GateType::SWAP) + ); + + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(2); + let bf = brute_force_weights(&catalog, 2); + for (syndrome, bf_logicals) in &bf { + let result = decoder.decode(syndrome); + for (logical, &bf_weight) in bf_logicals { + let dec_weight = result.logical_weights.get(logical).copied().unwrap_or(0.0); + assert!( + (dec_weight - bf_weight).abs() / bf_weight.max(1e-15) < 1e-8, + "new-gate decoder mismatch for syndrome={syndrome:?} logical={logical:?}: \ + decoder={dec_weight:.6e} brute_force={bf_weight:.6e}" + ); + } + } + } + + #[test] + fn test_k3_matches_brute_force() { + // Very small circuit to keep k=3 tractable + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("1".into()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String(r#"[{"records":[-1]}]"#.into()), + ); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let noise = StochasticNoiseParams { + p1: 0.01, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.01, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(3); + let bf = brute_force_weights(&catalog, 3); + + for (syndrome, bf_logicals) in &bf { + let result = decoder.decode(syndrome); + for (logical, &bf_weight) in bf_logicals { + let dec_weight = result.logical_weights.get(logical).copied().unwrap_or(0.0); + assert!( + (dec_weight - bf_weight).abs() / bf_weight.max(1e-15) < 1e-8, + "k=3 mismatch for syndrome={syndrome:?} logical={logical:?}: \ + decoder={dec_weight:.6e} brute_force={bf_weight:.6e}" + ); + } + } + } + + #[test] + fn test_cancellation() { + // Construct a catalog where syndrome {0} is explained by + // {0,1} XOR {1} (two faults cancelling detector 1). + let mut tc = TickCircuit::new(); + tc.tick().h(&[QubitId(0)]); + tc.tick().cx(&[(QubitId(0), QubitId(1))]); + tc.tick().h(&[QubitId(0)]); + tc.tick().mz(&[QubitId(0)]); + tc.tick().mz(&[QubitId(1)]); + tc.set_meta( + "num_measurements", + pecos_quantum::Attribute::String("2".into()), + ); + tc.set_meta( + "detectors", + pecos_quantum::Attribute::String(r#"[{"records":[-2]},{"records":[-1]}]"#.into()), + ); + tc.set_meta("observables", pecos_quantum::Attribute::String("[]".into())); + + let noise = StochasticNoiseParams { + p1: 0.01, + p2: 0.01, + p_meas: 0.01, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&tc, &noise).unwrap(); + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(2); + + // Check that syndrome [0] has k=2 explanations + let result = decoder.decode(&[0]); + let bf = brute_force_weights(&catalog, 2); + if let Some(bf_logicals) = bf.get(&vec![0]) { + for (logical, &bf_weight) in bf_logicals { + let dec_weight = result.logical_weights.get(logical).copied().unwrap_or(0.0); + assert!( + (dec_weight - bf_weight).abs() / bf_weight.max(1e-15) < 1e-8, + "Cancellation test mismatch" + ); + } + } + } + + #[test] + fn test_same_location_exclusion() { + let catalog = tiny_catalog(); + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(2); + + // Brute force already enforces location exclusion. + // Verify decoder matches for all syndromes. + let bf = brute_force_weights(&catalog, 2); + for (syndrome, bf_logicals) in &bf { + let result = decoder.decode(syndrome); + for (logical, &bf_weight) in bf_logicals { + let dec_weight = result.logical_weights.get(logical).copied().unwrap_or(0.0); + assert!( + (dec_weight - bf_weight).abs() / bf_weight.max(1e-15) < 1e-8, + "Location exclusion mismatch at syndrome={syndrome:?}" + ); + } + } + } + + #[test] + fn test_empty_syndrome_with_silent_alternatives() { + // Empty-detector alternatives contribute to empty syndrome + let catalog = tiny_catalog(); + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(1); + let result = decoder.decode(&[]); + + // k=0 contributes weight 1.0 for empty logical. + // k=1 empty-detector alternatives also contribute. + assert!( + result.logical_weights.contains_key(&vec![]), + "Empty logical should appear for empty syndrome" + ); + assert!( + *result.logical_weights.get(&vec![]).unwrap() >= 1.0, + "Empty-syndrome weight should be >= 1.0 (k=0 contributes 1)" + ); + } + + #[test] + fn test_odds_to_absolute_probability() { + let catalog = tiny_catalog(); + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(1); + let result = decoder.decode(&[0]); + + // Sum of odds weights * base_probability = sum of configuration_probabilities + let total_odds: f64 = result.logical_weights.values().sum(); + let total_abs = total_odds * decoder.base_probability(); + assert!(total_abs > 0.0, "Should have nonzero absolute probability"); + assert!(total_abs < 1.0, "Total probability should be < 1"); + } + + #[test] + fn test_k2_no_double_counting() { + let catalog = tiny_catalog(); + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(2); + let bf = brute_force_weights(&catalog, 2); + + // Check EVERY brute-force entry matches decoder exactly + for (syndrome, bf_logicals) in &bf { + let result = decoder.decode(syndrome); + let total_bf: f64 = bf_logicals.values().sum(); + let total_dec: f64 = result.logical_weights.values().sum(); + assert!( + (total_dec - total_bf).abs() / total_bf.max(1e-15) < 1e-8, + "k=2 total weight mismatch at syndrome={syndrome:?}: \ + decoder={total_dec:.6e} brute_force={total_bf:.6e}" + ); + } + } +} diff --git a/crates/pecos-qec/src/lib.rs b/crates/pecos-qec/src/lib.rs index f42cacc83..50100c2e2 100644 --- a/crates/pecos-qec/src/lib.rs +++ b/crates/pecos-qec/src/lib.rs @@ -63,32 +63,38 @@ //! assert_eq!(analysis.undetectable_logical, 0); //! ``` +pub mod dem_stab; pub mod distance; pub mod fault_tolerance; pub mod geometry; pub mod logical_discovery; +pub mod mem_stab; pub mod stabilizer_code; pub mod stabilizer_code_spec; pub mod surface; +pub use dem_stab::{DemStabError, DemStabShotBatch, DemStabSim, DemStabSimBuilder}; +pub use mem_stab::{MemStabError, MemStabSim, MemStabSimBuilder}; + pub use distance::{ DistanceResult, DistanceSearchConfig, LogicalOperatorInfo, WeightedPauliIterator, calculate_distance, find_min_weight_logicals, find_min_weight_logicals_with_info, }; pub use fault_tolerance::dem_builder::{ - DecomposedFault, DemBuilder, DemBuilderError, DetectorDef, DetectorErrorModel, FaultMechanism, - LogicalObservable, NoiseConfig, combine_probabilities, + DecomposedFault, DemBuilder, DemBuilderError, DemOutput, DetectorDef, DetectorErrorModel, + FaultMechanism, NoiseConfig, PecosDemMetadataError, combine_probabilities, }; pub use fault_tolerance::{ - CorrectionResult, DecoderAnalysis, ErrorClass, ErrorCorrectionChecker, ErrorCorrectionConfig, - ErrorCorrectionResult, FaultCheckConfig, FaultCheckResult, FaultChecker, FaultClass, - FaultConfiguration, FaultToleranceAnalysis, FaultToleranceFailure, LookupTableDecoder, - MeasurementRound, PauliFault, PauliFaultIterator, PauliPropChecker, PropagationResult, - SpacetimeLocation, StabilizerFlipAnalysis, StabilizerFlipChecker, StabilizerFlips, - SyndromeAnalysis, SyndromeClass, SyndromeHistory, SyndromeHistoryAnalysis, - SyndromeHistoryResult, anticommutes_with_logical, apply_recovery, classify_fault, - extract_measurement_rounds, extract_spacetime_locations, extract_syndrome, get_syndrome_flips, - has_syndrome, propagate_fault, propagate_faults, run_circuit_with_faults, run_correction_cycle, + CorrectionResult, DecoderAnalysis, DemOutputKind, DemOutputMetadata, ErrorClass, + ErrorCorrectionChecker, ErrorCorrectionConfig, ErrorCorrectionResult, FaultCheckConfig, + FaultCheckResult, FaultChecker, FaultClass, FaultConfiguration, FaultToleranceAnalysis, + FaultToleranceFailure, LookupTableDecoder, MeasurementRound, PauliFault, PauliFaultIterator, + PauliPropChecker, PropagationResult, SpacetimeLocation, StabilizerFlipAnalysis, + StabilizerFlipChecker, StabilizerFlips, SyndromeAnalysis, SyndromeClass, SyndromeHistory, + SyndromeHistoryAnalysis, SyndromeHistoryResult, anticommutes_with_logical, apply_recovery, + classify_fault, extract_measurement_rounds, extract_spacetime_locations, extract_syndrome, + get_syndrome_flips, has_syndrome, propagate_fault, propagate_faults, run_circuit_with_faults, + run_correction_cycle, }; pub use geometry::{CheckSchedule, LogicalOperator, PauliOp, StabilizerCheck, StabilizerColor}; pub use logical_discovery::{ diff --git a/crates/pecos-qec/src/mem_stab.rs b/crates/pecos-qec/src/mem_stab.rs new file mode 100644 index 000000000..098a2b419 --- /dev/null +++ b/crates/pecos-qec/src/mem_stab.rs @@ -0,0 +1,197 @@ +// 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. + +//! `MemStabSim` -- Clifford + depolarizing-family noise simulator that samples +//! **raw measurement outcomes** via a Measurement Noise Model (MNM). +//! +//! Sibling to [`crate::dem_stab::DemStabSim`]. Same underlying fault-influence +//! machinery, different aggregation level: +//! +//! | | `DemStabSim` | `MemStabSim` | +//! |---|---|---| +//! | Output | Detector + observable flips | Raw measurement outcomes | +//! | Use case | Research batch / decoder input | Classical-engine-facing backend | +//! | Backing primitive | `DemSampler` (DEM mechanisms) | `MeasurementNoiseModel` (MNM mechanisms) | +//! +//! Use `MemStabSim` when a classical control engine needs per-shot raw measurement +//! records (and will compute its own detectors/observables from them). Use +//! `DemStabSim` when you just want detector events for a decoder. +//! +//! # Scope +//! +//! Clifford circuits only, no classical feed-forward. For adaptive circuits use +//! `sparse_stab` + `pecos-neo` instead. For non-Clifford circuits use `CliffordRz`, +//! `STN`, or `MAST`. +//! +//! # Example +//! +//! ``` +//! use pecos_qec::mem_stab::MemStabSim; +//! use pecos_qec::fault_tolerance::dem_builder::NoiseConfig; +//! use pecos_quantum::DagCircuit; +//! use rand::SeedableRng; +//! use rand::rngs::SmallRng; +//! +//! let mut dag = DagCircuit::new(); +//! dag.pz(&[2]); +//! dag.cx(&[(0, 2)]); +//! dag.cx(&[(1, 2)]); +//! dag.mz(&[2]); +//! +//! let sim = MemStabSim::builder() +//! .circuit(dag) +//! .noise(NoiseConfig::uniform(0.01)) +//! .build() +//! .unwrap(); +//! +//! let mut rng = SmallRng::seed_from_u64(42); +//! let outcomes = sim.sample(&mut rng); +//! assert_eq!(outcomes.len(), sim.num_measurements()); +//! ``` + +use crate::fault_tolerance::dem_builder::{MeasurementNoiseModel, MemBuilder, NoiseConfig}; +use crate::fault_tolerance::propagator::DagFaultAnalyzer; +use pecos_quantum::DagCircuit; +use rand::Rng; +use thiserror::Error; + +/// Errors that can occur when building a [`MemStabSim`]. +#[derive(Debug, Error)] +pub enum MemStabError { + /// Builder called without a circuit. + #[error("MemStabSim requires a circuit; call .circuit(dag) before .build()")] + MissingCircuit, +} + +/// Clifford + depolarizing-family noise simulator that samples raw measurement outcomes. +/// +/// Built once via [`MemStabSim::builder`], sampled many times via [`Self::sample`] or +/// [`Self::sample_batch`]. The underlying [`MeasurementNoiseModel`] is constructed +/// eagerly at build time. +#[derive(Debug, Clone)] +pub struct MemStabSim { + mnm: MeasurementNoiseModel, +} + +impl MemStabSim { + /// Start building a [`MemStabSim`]. + #[must_use] + pub fn builder() -> MemStabSimBuilder { + MemStabSimBuilder::default() + } + + /// Number of measurements in the compiled circuit. + #[must_use] + pub fn num_measurements(&self) -> usize { + self.mnm.num_measurements + } + + /// Number of error mechanisms in the compiled MNM. + #[must_use] + pub fn num_mechanisms(&self) -> usize { + self.mnm.mechanisms.len() + } + + /// Access the underlying [`MeasurementNoiseModel`]. + #[must_use] + pub fn mnm(&self) -> &MeasurementNoiseModel { + &self.mnm + } + + /// Sample one shot of raw measurement outcomes. + /// + /// Length of the returned vector equals [`Self::num_measurements`]. + pub fn sample(&self, rng: &mut R) -> Vec { + self.mnm.sample(rng) + } + + /// Sample one shot into a preallocated buffer. + /// + /// `outcomes` must have length equal to [`Self::num_measurements`]; the buffer + /// is cleared before sampling. + pub fn sample_into(&self, outcomes: &mut [bool], rng: &mut R) { + self.mnm.sample_into(outcomes, rng); + } + + /// Sample `num_shots` independent shots. + /// + /// Returns a `Vec` of length `num_shots`; each inner vector has length + /// [`Self::num_measurements`]. + #[must_use] + pub fn sample_batch(&self, num_shots: usize, rng: &mut R) -> Vec> { + let mut out = Vec::with_capacity(num_shots); + let mut buf = vec![false; self.num_measurements()]; + for _ in 0..num_shots { + self.mnm.sample_into(&mut buf, rng); + out.push(buf.clone()); + } + out + } +} + +/// Builder for [`MemStabSim`]. +#[derive(Debug, Default)] +pub struct MemStabSimBuilder { + circuit: Option, + noise: NoiseConfig, + measurement_order: Option>, +} + +impl MemStabSimBuilder { + /// Set the circuit. Required. + #[must_use] + pub fn circuit(mut self, dag: DagCircuit) -> Self { + self.circuit = Some(dag); + self + } + + /// Set the noise configuration. + #[must_use] + pub fn noise(mut self, config: NoiseConfig) -> Self { + self.noise = config; + self + } + + /// Set the measurement order mapping from a `TickCircuit` (advanced). + #[must_use] + pub fn measurement_order(mut self, order: Vec) -> Self { + self.measurement_order = Some(order); + self + } + + /// Build the [`MemStabSim`], consuming the builder. + /// + /// # Errors + /// + /// Returns [`MemStabError::MissingCircuit`] if no circuit was set. + pub fn build(self) -> Result { + let dag = self.circuit.ok_or(MemStabError::MissingCircuit)?; + + let analyzer = DagFaultAnalyzer::new(&dag); + let influence_map = analyzer.build_influence_map(); + + let mut builder = MemBuilder::new(&influence_map).with_noise( + self.noise.p1, + self.noise.p2, + self.noise.p_meas, + self.noise.p_prep, + ); + + if let Some(order) = self.measurement_order { + builder = builder.with_measurement_order(order); + } + + Ok(MemStabSim { + mnm: builder.build(), + }) + } +} diff --git a/crates/pecos-qec/src/stabilizer_code.rs b/crates/pecos-qec/src/stabilizer_code.rs index 61c03795e..6f1eb5780 100644 --- a/crates/pecos-qec/src/stabilizer_code.rs +++ b/crates/pecos-qec/src/stabilizer_code.rs @@ -128,7 +128,7 @@ impl StabilizerCode { /// /// ``` /// use pecos_qec::StabilizerCode; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// // Repetition code [[3,1]]: logicals are X_L = XXX, Z_L = Z on any qubit /// let code = StabilizerCode::repetition(3); @@ -147,10 +147,10 @@ impl StabilizerCode { let mut stab_mat = F2Matrix::zeros(num_generators, 2 * n); for (row_idx, stab) in self.group.stabilizers().iter().enumerate() { for q in stab.x_positions() { - stab_mat.row_mut(row_idx)[q] = 1; + stab_mat.set(row_idx, q, 1); } for q in stab.z_positions() { - stab_mat.row_mut(row_idx)[n + q] = 1; + stab_mat.set(row_idx, n + q, 1); } } let (stab_rref, stab_pivots) = stab_mat.row_reduce(); @@ -167,7 +167,7 @@ impl StabilizerCode { for (row_idx, &pivot_col) in stab_pivots.iter().enumerate() { if v[pivot_col] == 1 { for (col, vi) in v.iter_mut().enumerate() { - *vi ^= stab_rref.row(row_idx)[col]; + *vi ^= stab_rref.get(row_idx, col); } } } @@ -182,11 +182,11 @@ impl StabilizerCode { if logical_vecs.len() > 1 { let mut log_mat = F2Matrix::zeros(logical_vecs.len(), 2 * n); for (i, v) in logical_vecs.iter().enumerate() { - log_mat.row_mut(i).clone_from(v); + log_mat.set_row(i, v); } let (reduced, _) = log_mat.row_reduce(); logical_vecs = (0..reduced.num_rows()) - .map(|i| reduced.row(i).to_vec()) + .map(|i| reduced.row(i)) .filter(|r| r.iter().any(|&b| b != 0)) .collect(); } @@ -215,7 +215,7 @@ impl StabilizerCode { /// /// ``` /// use pecos_qec::StabilizerCode; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// // Repetition code [[3,1,1]]: distance 1 (logical Z = Z on any single qubit) /// let code = StabilizerCode::repetition(3); @@ -283,7 +283,7 @@ impl StabilizerCode { /// /// ``` /// use pecos_qec::StabilizerCode; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// // Repetition code: ZZI, IZZ on 3 qubits /// let code = StabilizerCode::repetition(3); @@ -347,7 +347,7 @@ impl StabilizerCode { /// ``` #[must_use] pub fn repetition(n: usize) -> Self { - use pecos_core::pauli::constructors::Zs; + use pecos_core::pauli::Zs; assert!( n >= 2, "repetition code requires at least 2 qubits, got {n}" @@ -377,7 +377,7 @@ impl StabilizerCode { /// ``` #[must_use] pub fn steane() -> Self { - use pecos_core::pauli::constructors::{Xs, Zs}; + use pecos_core::pauli::{Xs, Zs}; let generators = vec![ Xs([0, 2, 4, 6]), Xs([1, 2, 5, 6]), @@ -411,7 +411,7 @@ impl StabilizerCode { /// ``` #[must_use] pub fn five_qubit() -> Self { - use pecos_core::pauli::constructors::{X, Z}; + use pecos_core::pauli::{X, Z}; let generators = vec![ X(0) & Z(1) & Z(2) & X(3), // XZZXI X(1) & Z(2) & Z(3) & X(4), // IXZZX @@ -441,7 +441,7 @@ impl StabilizerCode { /// ``` #[must_use] pub fn shor() -> Self { - use pecos_core::pauli::constructors::{Xs, Zs}; + use pecos_core::pauli::{Xs, Zs}; let generators = vec![ Xs([0, 1]), Xs([1, 2]), @@ -477,7 +477,7 @@ impl StabilizerCode { /// ``` #[must_use] pub fn four_two_two() -> Self { - use pecos_core::pauli::constructors::{Xs, Zs}; + use pecos_core::pauli::{Xs, Zs}; let generators = vec![Xs([0, 1, 2, 3]), Zs([0, 1, 2, 3])]; Self { group: PauliStabilizerGroup::from_generators_unchecked(generators), @@ -506,7 +506,7 @@ impl StabilizerCode { /// ``` #[must_use] pub fn toric(l: usize) -> Self { - use pecos_core::pauli::constructors::{Xs, Zs}; + use pecos_core::pauli::{Xs, Zs}; assert!(l >= 2, "toric code requires L >= 2, got {l}"); let n = 2 * l * l; @@ -557,7 +557,7 @@ impl StabilizerCode { #[cfg(test)] mod tests { use super::*; - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; // ======================================================================== // Basic code parameter tests diff --git a/crates/pecos-qec/src/stabilizer_code_spec.rs b/crates/pecos-qec/src/stabilizer_code_spec.rs index 7c2e4840b..e1d753106 100644 --- a/crates/pecos-qec/src/stabilizer_code_spec.rs +++ b/crates/pecos-qec/src/stabilizer_code_spec.rs @@ -938,10 +938,7 @@ impl StabilizerCodeSpecBuilder { self } - /// Adds a stabilizer from an `UnitaryRep`. - /// - /// The operator must be convertible to a `PauliString` (i.e., a Pauli operator - /// or tensor product of Pauli operators). + /// Adds a stabilizer Pauli operator. /// /// # Example /// @@ -956,18 +953,9 @@ impl StabilizerCodeSpecBuilder { /// .unwrap(); /// ``` /// - /// Accepts both `PauliString` and `UnitaryRep` (via `Into`). - /// - /// # Panics - /// - /// Panics if the operator cannot be converted to a `PauliString`. #[must_use] - pub fn check(mut self, op: impl Into) -> Self { - let ps = op - .into() - .try_to_pauli_string() - .expect("UnitaryRep must be convertible to PauliString"); - self.stabilizers.push(ps); + pub fn check(mut self, op: impl Into) -> Self { + self.stabilizers.push(op.into()); self } @@ -978,20 +966,10 @@ impl StabilizerCodeSpecBuilder { self } - /// Adds a logical Z operator. - /// - /// Accepts both `PauliString` and `UnitaryRep` (via `Into`). - /// - /// # Panics - /// - /// Panics if the operator cannot be converted to a `PauliString`. + /// Adds a logical Z Pauli operator. #[must_use] - pub fn logical_z(mut self, op: impl Into) -> Self { - let ps = op - .into() - .try_to_pauli_string() - .expect("UnitaryRep must be convertible to PauliString"); - self.logical_zs.push(ps); + pub fn logical_z(mut self, op: impl Into) -> Self { + self.logical_zs.push(op.into()); self } @@ -1002,20 +980,10 @@ impl StabilizerCodeSpecBuilder { self } - /// Adds a logical X operator. - /// - /// Accepts both `PauliString` and `UnitaryRep` (via `Into`). - /// - /// # Panics - /// - /// Panics if the operator cannot be converted to a `PauliString`. + /// Adds a logical X Pauli operator. #[must_use] - pub fn logical_x(mut self, op: impl Into) -> Self { - let ps = op - .into() - .try_to_pauli_string() - .expect("UnitaryRep must be convertible to PauliString"); - self.logical_xs.push(ps); + pub fn logical_x(mut self, op: impl Into) -> Self { + self.logical_xs.push(op.into()); self } @@ -1695,7 +1663,7 @@ mod tests { #[test] fn test_stabilizer_group_algebraic_analysis() { - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; // Use SurfaceCode and convert for algebraic analysis let surface = crate::SurfaceCode::rotated(3).unwrap(); @@ -1808,7 +1776,7 @@ mod tests { #[test] fn test_from_stabilizer_code_preserves_explicit_num_qubits() { - use pecos_core::pauli::constructors::Z; + use pecos_core::pauli::Z; use pecos_quantum::PauliStabilizerGroup; // StabilizerCode with explicit num_qubits > group touches diff --git a/crates/pecos-qec/tests/dem_sampler_tests.rs b/crates/pecos-qec/tests/dem_sampler_tests.rs index 46566dd08..f30fcdbe9 100644 --- a/crates/pecos-qec/tests/dem_sampler_tests.rs +++ b/crates/pecos-qec/tests/dem_sampler_tests.rs @@ -20,11 +20,10 @@ //! 3. Consistency with MNM (Measurement Noise Model) //! 4. Edge cases and boundary conditions -use pecos_qec::fault_tolerance::dem_builder::{DemSamplerBuilder, MemBuilder}; +use pecos_qec::fault_tolerance::dem_builder::DemSamplerBuilder; use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; use pecos_quantum::DagCircuit; -use rand::SeedableRng; -use rand::rngs::SmallRng; +use pecos_random::PecosRng; // ============================================================================ // Test Helpers @@ -74,12 +73,13 @@ fn test_zero_noise_produces_no_errors() { .with_noise(0.0, 0.0, 0.0, 0.0) .with_detectors_json(detectors_json) .unwrap() - .build(); + .build() + .unwrap(); // Zero noise should produce zero mechanisms assert_eq!(sampler.num_mechanisms(), 0); - let mut rng = SmallRng::seed_from_u64(42); + let mut rng = PecosRng::seed_from_u64(42); let stats = sampler.sample_statistics_with_rng(1000, &mut rng); assert_eq!(stats.logical_error_count, 0); @@ -102,13 +102,15 @@ fn test_mechanism_count_scales_with_circuit() { .with_noise(0.01, 0.01, 0.01, 0.01) .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) .unwrap() - .build(); + .build() + .unwrap(); let sampler2 = DemSamplerBuilder::new(&im2) .with_noise(0.01, 0.01, 0.01, 0.01) .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) .unwrap() - .build(); + .build() + .unwrap(); // Larger circuit should have more mechanisms assert!( @@ -129,11 +131,12 @@ fn test_deterministic_sampling_with_seed() { .with_noise(0.1, 0.1, 0.1, 0.1) .with_detectors_json(detectors_json) .unwrap() - .build(); + .build() + .unwrap(); // Same seed should produce same results - let mut rng1 = SmallRng::seed_from_u64(12345); - let mut rng2 = SmallRng::seed_from_u64(12345); + let mut rng1 = PecosRng::seed_from_u64(12345); + let mut rng2 = PecosRng::seed_from_u64(12345); let (det1, obs1) = sampler.sample_batch(100, &mut rng1); let (det2, obs2) = sampler.sample_batch(100, &mut rng2); @@ -154,10 +157,11 @@ fn test_different_seeds_produce_different_results() { .with_noise(0.1, 0.1, 0.1, 0.1) .with_detectors_json(detectors_json) .unwrap() - .build(); + .build() + .unwrap(); - let mut rng1 = SmallRng::seed_from_u64(12345); - let mut rng2 = SmallRng::seed_from_u64(54321); + let mut rng1 = PecosRng::seed_from_u64(12345); + let mut rng2 = PecosRng::seed_from_u64(54321); let (det1, _) = sampler.sample_batch(100, &mut rng1); let (det2, _) = sampler.sample_batch(100, &mut rng2); @@ -182,15 +186,17 @@ fn test_syndrome_rate_scales_with_noise() { .with_noise(0.001, 0.001, 0.001, 0.001) .with_detectors_json(detectors_json) .unwrap() - .build(); + .build() + .unwrap(); let sampler_high = DemSamplerBuilder::new(&influence_map) .with_noise(0.05, 0.05, 0.05, 0.05) .with_detectors_json(detectors_json) .unwrap() - .build(); + .build() + .unwrap(); - let mut rng = SmallRng::seed_from_u64(42); + let mut rng = PecosRng::seed_from_u64(42); let stats_low = sampler_low.sample_statistics_with_rng(10000, &mut rng); let stats_high = sampler_high.sample_statistics_with_rng(10000, &mut rng); @@ -217,9 +223,10 @@ fn test_syndrome_rate_reasonable_magnitude() { .with_noise(p, p, p, p) .with_detectors_json(detectors_json) .unwrap() - .build(); + .build() + .unwrap(); - let mut rng = SmallRng::seed_from_u64(42); + let mut rng = PecosRng::seed_from_u64(42); let stats = sampler.sample_statistics_with_rng(100_000, &mut rng); // With p=0.01, syndrome rate should be in a reasonable range @@ -253,17 +260,18 @@ fn test_observable_tracking() { .unwrap() .with_observables_json(observables_json) .unwrap() - .build(); + .build() + .unwrap(); assert_eq!(sampler.num_observables(), 1); - let mut rng = SmallRng::seed_from_u64(42); + let mut rng = PecosRng::seed_from_u64(42); let stats = sampler.sample_statistics_with_rng(10000, &mut rng); - // With observable tracking, we should see some logical errors + // With observable tracking, we should see some observable errors assert!( stats.logical_error_rate() > 0.0, - "Expected some logical errors with noise" + "Expected some observable errors with noise" ); } @@ -281,11 +289,12 @@ fn test_empty_detector_definitions() { .with_noise(0.01, 0.01, 0.01, 0.01) .with_detectors_json("[]") .unwrap() - .build(); + .build() + .unwrap(); assert_eq!(sampler.num_detectors(), 0); - let mut rng = SmallRng::seed_from_u64(42); + let mut rng = PecosRng::seed_from_u64(42); let (det_events, _) = sampler.sample(&mut rng); assert!(det_events.is_empty()); @@ -305,12 +314,13 @@ fn test_single_qubit_circuit() { .with_noise(0.01, 0.01, 0.01, 0.01) .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) .unwrap() - .build(); + .build() + .unwrap(); // Should have mechanisms from prep, H gate, and measurement assert!(sampler.num_mechanisms() > 0); - let mut rng = SmallRng::seed_from_u64(42); + let mut rng = PecosRng::seed_from_u64(42); let stats = sampler.sample_statistics_with_rng(1000, &mut rng); // Should produce some syndromes @@ -327,14 +337,15 @@ fn test_only_measurement_noise() { .with_noise(0.0, 0.0, 0.1, 0.0) // Only measurement noise .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) .unwrap() - .build(); + .build() + .unwrap(); assert!( sampler.num_mechanisms() > 0, "Should have mechanisms from measurement noise" ); - let mut rng = SmallRng::seed_from_u64(42); + let mut rng = PecosRng::seed_from_u64(42); let stats = sampler.sample_statistics_with_rng(10000, &mut rng); // Should produce syndromes from measurement errors @@ -354,14 +365,15 @@ fn test_only_two_qubit_noise() { .with_noise(0.0, 0.1, 0.0, 0.0) // Only two-qubit noise .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) .unwrap() - .build(); + .build() + .unwrap(); assert!( sampler.num_mechanisms() > 0, "Should have mechanisms from two-qubit noise" ); - let mut rng = SmallRng::seed_from_u64(42); + let mut rng = PecosRng::seed_from_u64(42); let stats = sampler.sample_statistics_with_rng(10000, &mut rng); // Should produce syndromes from CX errors @@ -384,24 +396,26 @@ fn test_dem_sampler_vs_mnm_mechanism_structure() { let p1 = 0.01; let p2 = 0.01; let p_meas = 0.01; - let p_init = 0.01; - - // Build MNM for comparison - let mnm = MemBuilder::new(&influence_map) - .with_noise(p1, p2, p_meas, p_init) - .build(); - - // Build DemSampler - let sampler = DemSamplerBuilder::new(&influence_map) - .with_noise(p1, p2, p_meas, p_init) - .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) - .unwrap() - .build(); - - // MNM mechanisms are at measurement level, DemSampler at detector level - // They should both have non-zero counts - assert!(mnm.num_mechanisms() > 0); - assert!(sampler.num_mechanisms() > 0); + let p_prep = 0.01; + + // DemSampler raw mode (replaces MemBuilder) + let raw_sampler = DemSamplerBuilder::new(&influence_map) + .with_noise(p1, p2, p_meas, p_prep) + .raw_measurements() + .build() + .unwrap(); + + // DemSampler detector mode (replaces DemSamplerBuilder for this use case) + let det_records = vec![vec![-1i32]]; + let det_sampler = DemSamplerBuilder::new(&influence_map) + .with_noise(p1, p2, p_meas, p_prep) + .with_detectors(det_records, vec![]) + .build() + .unwrap(); + + // Both should have non-zero mechanism counts + assert!(raw_sampler.num_mechanisms() > 0); + assert!(det_sampler.num_mechanisms() > 0); } #[test] @@ -419,11 +433,12 @@ fn test_multi_detector_circuit() { .with_noise(0.01, 0.01, 0.01, 0.01) .with_detectors_json(detectors_json) .unwrap() - .build(); + .build() + .unwrap(); assert_eq!(sampler.num_detectors(), 2); - let mut rng = SmallRng::seed_from_u64(42); + let mut rng = PecosRng::seed_from_u64(42); let (det_events, _) = sampler.sample_batch(1000, &mut rng); // Should get events on both detectors @@ -448,9 +463,10 @@ fn test_batch_sampling_performance() { .with_noise(0.01, 0.01, 0.01, 0.01) .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) .unwrap() - .build(); + .build() + .unwrap(); - let mut rng = SmallRng::seed_from_u64(42); + let mut rng = PecosRng::seed_from_u64(42); // Should be able to sample many shots quickly let num_shots = 100_000; @@ -483,21 +499,22 @@ fn test_statistics_vs_batch_consistency() { .unwrap() .with_observables_json(observables_json) .unwrap() - .build(); + .build() + .unwrap(); let num_shots = 10000; // Sample with statistics method (uses geometric skip) - let mut rng1 = SmallRng::seed_from_u64(42); + let mut rng1 = PecosRng::seed_from_u64(42); let stats = sampler.sample_statistics_with_rng(num_shots, &mut rng1); // Sample with batch method (uses per-shot threshold) - let mut rng2 = SmallRng::seed_from_u64(123); // Different seed since algorithms differ + let mut rng2 = PecosRng::seed_from_u64(123); // Different seed since algorithms differ let (det_events, obs_flips) = sampler.sample_batch(num_shots, &mut rng2); // Count from batch results let batch_syndromes = det_events.iter().filter(|d| d.iter().any(|&x| x)).count(); - let batch_logical = obs_flips.iter().filter(|o| o.iter().any(|&x| x)).count(); + let batch_observable = obs_flips.iter().filter(|o| o.iter().any(|&x| x)).count(); // Should be statistically similar (within 10% relative difference) let stats_rate = stats.syndrome_count as f64 / num_shots as f64; @@ -509,11 +526,11 @@ fn test_statistics_vs_batch_consistency() { ); let stats_logical_rate = stats.logical_error_count as f64 / num_shots as f64; - let batch_logical_rate = batch_logical as f64 / num_shots as f64; - let logical_rel_diff = (stats_logical_rate - batch_logical_rate).abs() + let batch_logical_rate = batch_observable as f64 / num_shots as f64; + let observable_rel_diff = (stats_logical_rate - batch_logical_rate).abs() / stats_logical_rate.max(batch_logical_rate).max(0.001); assert!( - logical_rel_diff < 0.1, - "Logical error rates should be similar: stats={stats_logical_rate:.4} batch={batch_logical_rate:.4} rel_diff={logical_rel_diff:.2}" + observable_rel_diff < 0.1, + "Logical error rates should be similar: stats={stats_logical_rate:.4} batch={batch_logical_rate:.4} rel_diff={observable_rel_diff:.2}" ); } diff --git a/crates/pecos-qec/tests/dem_stab_tests.rs b/crates/pecos-qec/tests/dem_stab_tests.rs new file mode 100644 index 000000000..3b867c711 --- /dev/null +++ b/crates/pecos-qec/tests/dem_stab_tests.rs @@ -0,0 +1,156 @@ +// 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 `DemStabSim`. +//! +//! Parity: `DemStabSim` must produce identical shot batches to the raw +//! `DagFaultAnalyzer` + `DemSamplerBuilder` pipeline given equal inputs and seeds. + +use pecos_qec::dem_stab::{DemStabError, DemStabSim}; +use pecos_qec::fault_tolerance::dem_builder::{ + DemOutput, DemSamplerBuilder, DetectorDef, NoiseConfig, +}; +use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; +use pecos_quantum::DagCircuit; +use rand::SeedableRng; +use rand::rngs::SmallRng; + +fn repetition_code_circuit() -> DagCircuit { + let mut dag = DagCircuit::new(); + // 3 data qubits (0, 1, 2), 2 ancillas (3, 4) + dag.pz(&[3]); + dag.pz(&[4]); + dag.cx(&[(0, 3)]); + dag.cx(&[(1, 3)]); + dag.cx(&[(1, 4)]); + dag.cx(&[(2, 4)]); + dag.mz(&[3]); + dag.mz(&[4]); + dag +} + +fn detectors() -> Vec { + vec![ + DetectorDef::new(0).with_records([-2]), + DetectorDef::new(1).with_records([-1]), + ] +} + +fn observables() -> Vec { + vec![DemOutput::new(0).with_records([-2, -1])] +} + +#[test] +fn builder_rejects_missing_circuit() { + let err = DemStabSim::builder().build().unwrap_err(); + assert!(matches!(err, DemStabError::MissingCircuit)); +} + +#[test] +fn zero_noise_produces_zero_mechanisms() { + let sim = DemStabSim::builder() + .circuit(repetition_code_circuit()) + .noise(NoiseConfig::uniform(0.0)) + .detectors(detectors()) + .observables(observables()) + .build() + .unwrap(); + + assert_eq!(sim.num_mechanisms(), 0); + assert_eq!(sim.num_detectors(), 2); + assert_eq!(sim.num_observables(), 1); +} + +#[test] +fn parity_with_raw_pipeline() { + let noise = NoiseConfig::uniform(0.01); + let shots = 512; + let seed = 0xDEAD_BEEF_u64; + + // Path 1: DemStabSim. + let sim = DemStabSim::builder() + .circuit(repetition_code_circuit()) + .noise(noise.clone()) + .detectors(detectors()) + .observables(observables()) + .build() + .unwrap(); + let mut rng1 = SmallRng::seed_from_u64(seed); + let batch = sim.sample_batch(shots, &mut rng1); + + // Path 2: raw pipeline, identical inputs + identical RNG seed. + let dag = repetition_code_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence_map = analyzer.build_influence_map(); + let det_records: Vec> = detectors().iter().map(|d| d.records.to_vec()).collect(); + let obs_records: Vec> = observables().iter().map(|o| o.records.to_vec()).collect(); + let sampler = DemSamplerBuilder::new(&influence_map) + .with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep) + .with_detector_records(det_records) + .with_observable_records(obs_records) + .build() + .unwrap(); + let mut rng2 = SmallRng::seed_from_u64(seed); + let (det_raw, obs_raw) = sampler.sample_batch(shots, &mut rng2); + + assert_eq!(batch.detector_flips, det_raw); + assert_eq!(batch.observable_flips, obs_raw); +} + +#[test] +fn shot_batch_shape_is_correct() { + let sim = DemStabSim::builder() + .circuit(repetition_code_circuit()) + .noise(NoiseConfig::uniform(0.005)) + .detectors(detectors()) + .observables(observables()) + .build() + .unwrap(); + + let mut rng = SmallRng::seed_from_u64(7); + let batch = sim.sample_batch(16, &mut rng); + + assert_eq!(batch.detector_flips.len(), 16); + assert_eq!(batch.observable_flips.len(), 16); + for row in &batch.detector_flips { + assert_eq!(row.len(), sim.num_detectors()); + } + for row in &batch.observable_flips { + assert_eq!(row.len(), sim.num_observables()); + } +} + +#[test] +fn nonzero_noise_yields_some_flips() { + // Sanity check: at p=0.1 with 1000 shots we should see plenty of flips. + let sim = DemStabSim::builder() + .circuit(repetition_code_circuit()) + .noise(NoiseConfig::uniform(0.1)) + .detectors(detectors()) + .observables(observables()) + .build() + .unwrap(); + + let mut rng = SmallRng::seed_from_u64(123); + let batch = sim.sample_batch(1000, &mut rng); + + let total_det_flips: usize = batch + .detector_flips + .iter() + .map(|row| row.iter().filter(|&&b| b).count()) + .sum(); + + assert!( + total_det_flips > 0, + "expected some detector flips at p=0.1 over 1000 shots" + ); +} diff --git a/crates/pecos-qec/tests/fault_enumeration_example.rs b/crates/pecos-qec/tests/fault_enumeration_example.rs new file mode 100644 index 000000000..eb2010a1a --- /dev/null +++ b/crates/pecos-qec/tests/fault_enumeration_example.rs @@ -0,0 +1,793 @@ +// 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. + +// Shot counts and error counters are small integers; precision loss in f64 is not a concern. +#![allow(clippy::cast_precision_loss)] + +//! Example: Repetition code d=3 with 3 rounds of syndrome extraction. +//! +//! Demonstrates the full QEC workflow: +//! 1. Build the circuit with annotations (detectors, observables, tracked Paulis) +//! 2. Build the fault influence map +//! 3. Enumerate fault combinations up to weight 3 +//! 4. Classify errors (detectable, undetectable, logical) + +use pecos_core::pauli::X; +use pecos_qec::fault_tolerance::InfluenceBuilder; +use pecos_quantum::DagCircuit; + +/// Build a repetition code d=3 circuit with `num_rounds` syndrome extraction rounds. +/// +/// Layout: +/// Data qubits: 0, 1, 2 +/// Z-ancillas: 3 (measures `Z_0` `Z_1`), 4 (measures `Z_1` `Z_2`) +/// +/// Each round: prep ancillas, CNOT syndrome extraction, measure ancillas. +/// After the last round: measure all data qubits for final readout. +fn build_repetition_code(num_rounds: usize) -> DagCircuit { + let mut dag = DagCircuit::new(); + + // Data qubits + let data: Vec = vec![0, 1, 2]; + // Ancilla qubits: one per stabilizer + let ancilla_01 = 3; // measures Z_0 Z_1 + let ancilla_12 = 4; // measures Z_1 Z_2 + + // Initialize data qubits in |0⟩ + dag.pz(&data); + + // Track measurements across rounds for detector definitions + let mut prev_meas_01 = None; + let mut prev_meas_12 = None; + + for round in 0..num_rounds { + // Prep ancillas + dag.pz(&[ancilla_01, ancilla_12]); + + // Syndrome extraction: CX from data to ancilla + // Z_0 Z_1 stabilizer + dag.cx(&[(data[0], ancilla_01)]); + dag.cx(&[(data[1], ancilla_01)]); + // Z_1 Z_2 stabilizer + dag.cx(&[(data[1], ancilla_12)]); + dag.cx(&[(data[2], ancilla_12)]); + + // Measure ancillas + let ms_01 = dag.mz(&[ancilla_01]); + let ms_12 = dag.mz(&[ancilla_12]); + + // Detectors + if round == 0 { + // First round: each measurement should be 0 (fresh code state) + dag.detector_labeled(&format!("Z01_r{round}"), &[ms_01[0]]); + dag.detector_labeled(&format!("Z12_r{round}"), &[ms_12[0]]); + } else { + // Subsequent rounds: compare with previous round + dag.detector_labeled(&format!("Z01_r{round}"), &[prev_meas_01.unwrap(), ms_01[0]]); + dag.detector_labeled(&format!("Z12_r{round}"), &[prev_meas_12.unwrap(), ms_12[0]]); + } + + prev_meas_01 = Some(ms_01[0]); + prev_meas_12 = Some(ms_12[0]); + } + + // Final data qubit measurements + let ms_data = dag.mz(&data); + + // Final detectors: compare last syndrome round with data measurements + // Z_0 Z_1 from data should match last ancilla measurement + dag.detector_labeled( + "Z01_final", + &[ms_data[0], ms_data[1], prev_meas_01.unwrap()], + ); + // Z_1 Z_2 from data should match last ancilla measurement + dag.detector_labeled( + "Z12_final", + &[ms_data[1], ms_data[2], prev_meas_12.unwrap()], + ); + + // Observable: logical Z readout = Z_0 (any single data qubit works for rep code) + dag.observable_labeled("logical_Z", &[ms_data[0]]); + + // Pauli operator: track logical X = X_0 X_1 X_2 + dag.tracked_pauli_labeled("logical_X", X(0) & X(1) & X(2)); + + dag +} + +#[test] +fn repetition_code_fault_enumeration() { + let dag = build_repetition_code(3); + + println!("Circuit: {} gates", dag.gate_count()); + println!("Annotations:"); + for ann in dag.annotations() { + let kind = match &ann.kind { + pecos_quantum::AnnotationKind::Detector { .. } => "detector", + pecos_quantum::AnnotationKind::Observable { .. } => "observable", + pecos_quantum::AnnotationKind::TrackedPauli => "tracked_pauli", + }; + let label = ann.label.as_deref().unwrap_or("(none)"); + println!(" {kind:10} {label:15} {}", ann.pauli); + } + + // Build influence map (InfluenceBuilder handles annotations) + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + let locs = map.gate_fault_locations(); + println!( + "\nFault locations: {} (grouped from {} per-qubit locations)", + locs.len(), + map.locations.len() + ); + println!( + "Detectors: {}, DEM outputs: {}", + map.detectors.len(), + map.influences.max_dem_output_index().map_or(0, |i| i + 1) + ); + + // Show all fault locations and their possible faults + println!("\n--- Fault locations ---"); + for (i, loc) in locs.iter().enumerate() { + let timing = if loc.before { "before" } else { "after" }; + let qubit_list: Vec = loc.qubits.iter().map(pecos_core::QubitId::index).collect(); + let num_faults = loc.possible_faults().len(); + println!( + " loc {i:2}: {:3?} {timing:6} qubits={qubit_list:?} ({num_faults} faults)", + loc.gate_type + ); + } + + // Weight-1 analysis + let mut w1_detectable = 0usize; + let mut w1_undetectable = 0usize; + let mut w1_trivial = 0usize; + let mut w1_total = 0usize; + + map.for_each_fault_combo(1, |combo| { + w1_total += 1; + let has_det = !combo.effect.detectors.is_empty(); + let has_dem_output = !combo.effect.dem_outputs.is_empty(); + match (has_det, has_dem_output) { + (true, _) => w1_detectable += 1, + (false, true) => w1_undetectable += 1, + (false, false) => w1_trivial += 1, + } + }); + println!("\n--- Weight-1 faults ---"); + println!(" Total: {w1_total}"); + println!(" Detectable: {w1_detectable}"); + println!(" Undetectable: {w1_undetectable}"); + println!(" Trivial: {w1_trivial}"); + // The repetition code only detects X errors (via Z stabilizers). + // Z errors on data qubits are undetectable -- this is expected. + // The undetectable errors flip logical_X (index 1) since Z anticommutes with X. + assert!( + w1_undetectable > 0, + "Z errors should be undetectable in the repetition code" + ); + + // Weight-2 analysis + let mut w2_detectable = 0usize; + let mut w2_undetectable = 0usize; + let mut w2_trivial = 0usize; + let mut w2_total = 0usize; + + map.for_each_fault_combo(2, |combo| { + w2_total += 1; + let has_det = !combo.effect.detectors.is_empty(); + let has_dem_output = !combo.effect.dem_outputs.is_empty(); + match (has_det, has_dem_output) { + (true, _) => w2_detectable += 1, + (false, true) => w2_undetectable += 1, + (false, false) => w2_trivial += 1, + } + }); + println!("\n--- Weight-2 faults ---"); + println!(" Total: {w2_total}"); + println!(" Detectable: {w2_detectable}"); + println!(" Undetectable: {w2_undetectable}"); + println!(" Trivial: {w2_trivial}"); + // Weight-2 also has undetectable Z errors (Z type is not protected). + + // Weight-3: this is where d=3 codes can have undetectable errors + let mut w3_detectable = 0usize; + let mut w3_undetectable = 0usize; + let mut w3_trivial = 0usize; + let mut w3_total = 0usize; + let mut w3_undetectable_examples: Vec = Vec::new(); + + map.for_each_fault_combo(3, |combo| { + w3_total += 1; + let has_det = !combo.effect.detectors.is_empty(); + let has_dem_output = !combo.effect.dem_outputs.is_empty(); + match (has_det, has_dem_output) { + (true, _) => w3_detectable += 1, + (false, true) => { + w3_undetectable += 1; + // Collect first few examples + if w3_undetectable_examples.len() < 5 { + let desc: Vec = combo + .components + .iter() + .map(|c| { + let loc = &locs[c.location_index]; + let timing = if loc.before { "before" } else { "after" }; + format!( + "{} {} {:?} q={:?}", + c.event.pauli, + timing, + loc.gate_type, + loc.qubits + .iter() + .map(pecos_core::QubitId::index) + .collect::>() + ) + }) + .collect(); + w3_undetectable_examples.push(desc.join(" + ")); + } + } + (false, false) => w3_trivial += 1, + } + }); + println!("\n--- Weight-3 faults ---"); + println!(" Total: {w3_total}"); + println!(" Detectable: {w3_detectable}"); + println!(" Undetectable: {w3_undetectable}"); + println!(" Trivial: {w3_trivial}"); + + if !w3_undetectable_examples.is_empty() { + println!("\n Example undetectable w=3 errors:"); + for ex in &w3_undetectable_examples { + println!(" {ex}"); + } + } + + // The d=3 repetition code can correct any single fault, so: + // - No undetectable errors at w=1 or w=2 + // - Some undetectable errors at w=3 (this is the code distance) + println!("\n--- Summary ---"); + println!(" The repetition code detects X errors (via Z stabilizers) but not Z errors."); + println!(" Undetectable errors at all weights are Z-type faults flipping logical_X."); + + // Also demonstrate single-event introspection + println!("\n--- Single event example ---"); + let first_cx_loc = locs + .iter() + .find(|l| l.gate_type == pecos_core::gate_type::GateType::CX && !l.before); + if let Some(loc) = first_cx_loc { + let qubit_list: Vec = loc.qubits.iter().map(pecos_core::QubitId::index).collect(); + println!(" CX after, qubits={qubit_list:?}:"); + for event in loc.events() { + if !event.detectors.is_empty() || !event.dem_outputs.is_empty() { + println!( + " {} -> dets={:?} dem_outputs={:?} meas={:?}", + event.pauli, event.detectors, event.dem_outputs, event.measurements + ); + } + } + } +} + +/// Test that labels are accessible from the influence map during fault introspection. +#[test] +fn repetition_code_labels() { + let dag = build_repetition_code(1); // 1 round for simplicity + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + // Check DEM-output labels are populated (observables + tracked Paulis) + println!("DEM output labels: {:?}", map.dem_output_labels); + // 1 observable (logical_Z) + 1 tracked Pauli (logical_X) = 2 labels + assert_eq!(map.dem_output_labels.len(), 2); + assert_eq!(map.num_dem_outputs(), 1, "1 observable"); + assert_eq!(map.num_tracked_paulis(), 1, "1 tracked Pauli"); + + // Labels accessible via internal index + assert_eq!(map.dem_output_label(0), Some("logical_Z")); + assert_eq!(map.dem_output_label(1), Some("logical_X")); + assert_eq!(map.dem_output_label(99), None); + + // Use labels during fault introspection + let locs = map.gate_fault_locations(); + let mut found_labeled_event = false; + for loc in &locs { + for event in loc.events() { + for &output_idx in &event.dem_outputs { + if let Some(label) = map.dem_output_label(output_idx as usize) { + println!(" {} at {:?} flips {label}", event.pauli, loc.gate_type); + found_labeled_event = true; + } + } + } + } + assert!( + found_labeled_event, + "Should find at least one labeled logical event" + ); +} + +/// Demonstrate building a lookup table from the influence map. +#[test] +fn repetition_code_lookup_table() { + let dag = build_repetition_code(3); + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + // Build lookup: syndrome pattern -> list of possible logical effects + let mut lookup: std::collections::BTreeMap, Vec> = + std::collections::BTreeMap::new(); + + // Weight-1 faults define the basic lookup + map.for_each_fault_combo(1, |combo| { + if !combo.effect.detectors.is_empty() { + let mut syndrome = combo.effect.detectors.clone(); + syndrome.sort_unstable(); + lookup + .entry(syndrome) + .or_default() + .push(combo.effect.pauli.clone()); + } + }); + + println!("Lookup table (weight-1 syndromes):"); + for (syndrome, faults) in &lookup { + println!(" syndrome {syndrome:?} <- {} fault(s)", faults.len()); + } + + // Verify: each non-trivial weight-1 syndrome maps to a correction + assert!( + !lookup.is_empty(), + "Should have at least one syndrome pattern" + ); +} + +/// Build an ML lookup table decoder and test it against sampled errors. +#[test] +fn repetition_code_ml_decoder() { + use pecos_qec::fault_tolerance::dem_builder::{DemSampler, NoiseConfig}; + use pecos_qec::fault_tolerance::lookup_decoder::LookupDecoder; + use rand::SeedableRng; + + let dag = build_repetition_code(3); + let noise = NoiseConfig::uniform(0.001); + + // Build influence map + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + // Build ML decoder from fault enumeration up to weight 3 + let decoder = LookupDecoder::build(&map, &noise, 3); + + println!("ML Decoder:"); + println!(" Syndrome patterns: {}", decoder.num_syndromes()); + println!(" Observables: {}", decoder.num_observables()); + println!(" Max weight: {}", decoder.max_weight()); + + // Build sampler for testing + let sampler = DemSampler::from_circuit(&dag, &noise).unwrap(); + + // Sample and decode + let mut rng = rand::rngs::SmallRng::seed_from_u64(42); + let num_shots = 100_000; + let mut _correct = 0usize; + let mut total_errors = 0usize; + let mut unknown_syndromes = 0usize; + + for _ in 0..num_shots { + if let Some(dual) = sampler.sample_dual(&mut rng) { + let result = decoder.decode_from_bools(&dual.detector_events); + if !result.known_syndrome { + unknown_syndromes += 1; + } + + // Check: did the decoder's correction fix the logical? + // The actual DEM-output outcome is dual.dem_output_flips. + // After applying the correction, the residual should be identity. + let has_observable_error: bool = dual + .dem_output_flips + .iter() + .zip(&result.corrections) + .any(|(&flip, &corr)| flip ^ corr); // residual error + + if has_observable_error { + total_errors += 1; + } else { + _correct += 1; + } + } + } + + let error_rate = total_errors as f64 / num_shots as f64; + let raw_error_rate = sampler + .sample_statistics(num_shots, 42) + .logical_error_rate(); + + println!("\n Shots: {num_shots}"); + println!(" Unknown syndromes: {unknown_syndromes}"); + println!(" Raw observable rate: {raw_error_rate:.6}"); + println!(" Decoded error rate:{error_rate:.6}"); + println!( + " Improvement: {:.1}x", + raw_error_rate / error_rate.max(1e-10) + ); + + // The decoder should reduce the observable error rate compared to raw + // (for the repetition code with Z errors unprotected, improvement is modest + // since only X errors are correctable, but it should still help) + assert!( + total_errors < num_shots, + "Decoder should correct at least some errors" + ); +} + +/// Test decoder correctness: empty syndrome should produce no corrections. +#[test] +fn decoder_empty_syndrome() { + use pecos_qec::fault_tolerance::dem_builder::NoiseConfig; + use pecos_qec::fault_tolerance::lookup_decoder::LookupDecoder; + + let dag = build_repetition_code(1); + let noise = NoiseConfig::uniform(0.01); + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + let decoder = LookupDecoder::build(&map, &noise, 2); + + // Empty syndrome = no detectors fired = most likely no error + let result = decoder.decode(&[]); + assert!(result.known_syndrome, "Empty syndrome should be known"); + assert!( + result.corrections.iter().all(|&c| !c), + "Empty syndrome should produce no corrections: {:?}", + result.corrections + ); +} + +/// Test that the decoder table grows with weight, and truncation bound works. +#[test] +fn decoder_table_size_and_truncation() { + use pecos_qec::fault_tolerance::dem_builder::NoiseConfig; + use pecos_qec::fault_tolerance::lookup_decoder::LookupDecoder; + + let dag = build_repetition_code(1); + let noise = NoiseConfig::uniform(0.001); + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + let d1 = LookupDecoder::build(&map, &noise, 1); + let d2 = LookupDecoder::build(&map, &noise, 2); + let d3 = LookupDecoder::build(&map, &noise, 3); + + println!( + "Weight 1: {} syndromes, accounted={:.8}, truncation={:.2e}", + d1.num_syndromes(), + d1.accounted_probability(), + d1.truncation_bound() + ); + println!( + "Weight 2: {} syndromes, accounted={:.8}, truncation={:.2e}", + d2.num_syndromes(), + d2.accounted_probability(), + d2.truncation_bound() + ); + println!( + "Weight 3: {} syndromes, accounted={:.8}, truncation={:.2e}", + d3.num_syndromes(), + d3.accounted_probability(), + d3.truncation_bound() + ); + + // Higher weight covers more probability mass + assert!(d2.accounted_probability() >= d1.accounted_probability()); + assert!(d3.accounted_probability() >= d2.accounted_probability()); + + // At p=0.001, weight-3 should cover essentially all probability mass + assert!( + d3.truncation_bound() < 1e-6, + "Weight 3 at p=0.001 should have negligible truncation: {}", + d3.truncation_bound() + ); + + // Higher weight should discover at least as many syndromes + assert!(d2.num_syndromes() >= d1.num_syndromes()); + assert!(d1.num_syndromes() > 1); +} + +// ============================================================================ +// [[4,2,2]] Code Example +// ============================================================================ + +/// Build a [[4,2,2]] code circuit with `num_rounds` of syndrome extraction. +/// +/// The [[4,2,2]] code: +/// - 4 data qubits (0-3), 2 ancilla qubits (4-5) +/// - Stabilizers: `X_0` `X_1` `X_2` `X_3` and `Z_0` `Z_1` `Z_2` `Z_3` +/// - 2 logical qubits: +/// - Logical `Z_1` = `Z_0` `Z_1`, Logical `X_1` = `X_0` `X_2` +/// - Logical `Z_2` = `Z_0` `Z_2`, Logical `X_2` = `X_0` `X_1` +/// - Distance 2: detects any single-qubit error (cannot correct) +/// +/// X stabilizer measurement (ancilla 4): +/// Prep |+⟩, CX(ancilla, data) for each data qubit, H, MZ +/// +/// Z stabilizer measurement (ancilla 5): +/// Prep |0⟩, CX(data, ancilla) for each data qubit, MZ +fn build_422_code(num_rounds: usize) -> DagCircuit { + let mut dag = DagCircuit::new(); + + let data: Vec = vec![0, 1, 2, 3]; + let ancilla_x = 4; // measures X_0 X_1 X_2 X_3 + let ancilla_z = 5; // measures Z_0 Z_1 Z_2 Z_3 + + // Initialize data qubits + dag.pz(&data); + + let mut prev_meas_x = None; + let mut prev_meas_z = None; + + for round in 0..num_rounds { + // --- X stabilizer: X_0 X_1 X_2 X_3 --- + dag.pz(&[ancilla_x]); + dag.h(&[ancilla_x]); // prep |+⟩ + // CX(ancilla, data) propagates X from ancilla to data + dag.cx(&[ + (ancilla_x, data[0]), + (ancilla_x, data[1]), + (ancilla_x, data[2]), + (ancilla_x, data[3]), + ]); + dag.h(&[ancilla_x]); // rotate back to Z basis + let ms_x = dag.mz(&[ancilla_x]); + + // --- Z stabilizer: Z_0 Z_1 Z_2 Z_3 --- + dag.pz(&[ancilla_z]); + // CX(data, ancilla) propagates Z from data to ancilla + dag.cx(&[ + (data[0], ancilla_z), + (data[1], ancilla_z), + (data[2], ancilla_z), + (data[3], ancilla_z), + ]); + let ms_z = dag.mz(&[ancilla_z]); + + // Detectors + if round == 0 { + // Z stabilizer is deterministic on |0000⟩ (Z eigenstate) + dag.detector_labeled(&format!("Sz_r{round}"), &[ms_z[0]]); + // X stabilizer is NOT deterministic on |0000⟩ -- no standalone detector. + // First X measurement is a random coin flip; only round-to-round + // comparisons are valid detectors. + } else { + dag.detector_labeled(&format!("Sx_r{round}"), &[prev_meas_x.unwrap(), ms_x[0]]); + dag.detector_labeled(&format!("Sz_r{round}"), &[prev_meas_z.unwrap(), ms_z[0]]); + } + + prev_meas_x = Some(ms_x[0]); + prev_meas_z = Some(ms_z[0]); + } + + // Final data qubit measurements + let ms_data = dag.mz(&data); + + // Final detector: Z stabilizer from data should match last Z-ancilla. + // Z_0 Z_1 Z_2 Z_3 is readable from Z-basis data measurements. + dag.detector_labeled( + "Sz_final", + &[ + ms_data[0], + ms_data[1], + ms_data[2], + ms_data[3], + prev_meas_z.unwrap(), + ], + ); + // No final X-stabilizer detector: Z-basis data measurements cannot + // reconstruct X_0 X_1 X_2 X_3 parity. + + // Observables: logical Z readouts + // Logical Z_1 = Z_0 Z_1 + dag.observable_labeled("logical_Z1", &[ms_data[0], ms_data[1]]); + // Logical Z_2 = Z_0 Z_2 + dag.observable_labeled("logical_Z2", &[ms_data[0], ms_data[2]]); + + // Tracked Paulis: logical X operators + // Logical X_1 = X_0 X_2 + dag.tracked_pauli_labeled("logical_X1", X(0) & X(2)); + // Logical X_2 = X_0 X_1 + dag.tracked_pauli_labeled("logical_X2", X(0) & X(1)); + + dag +} + +#[test] +fn code_422_fault_enumeration() { + let dag = build_422_code(2); + + println!("[[4,2,2]] Code with 2 rounds"); + println!("Circuit: {} gates", dag.gate_count()); + println!("Annotations:"); + for ann in dag.annotations() { + let kind = match &ann.kind { + pecos_quantum::AnnotationKind::Detector { .. } => "detector", + pecos_quantum::AnnotationKind::Observable { .. } => "observable", + pecos_quantum::AnnotationKind::TrackedPauli => "tracked_pauli", + }; + let label = ann.label.as_deref().unwrap_or("(none)"); + println!(" {kind:10} {label:15} {}", ann.pauli); + } + + // Build influence map + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + let locs = map.gate_fault_locations(); + println!( + "\nFault locations: {} (from {} per-qubit locations)", + locs.len(), + map.locations.len() + ); + println!( + "Detectors: {}, DEM outputs: {}", + map.detectors.len(), + map.influences.max_dem_output_index().map_or(0, |i| i + 1) + ); + + // Weight-1 + let mut w1_total = 0usize; + let mut w1_detectable = 0usize; + let mut w1_undetectable = 0usize; + let mut w1_trivial = 0usize; + + map.for_each_fault_combo(1, |combo| { + w1_total += 1; + let has_det = !combo.effect.detectors.is_empty(); + let has_dem_output = !combo.effect.dem_outputs.is_empty(); + match (has_det, has_dem_output) { + (true, _) => w1_detectable += 1, + (false, true) => { + w1_undetectable += 1; + if w1_undetectable <= 5 { + let c = &combo.components[0]; + let loc = &locs[c.location_index]; + let timing = if loc.before { "before" } else { "after" }; + println!( + " UNDET w=1: {} {timing} {:?} q={:?} -> dem_outputs={:?}", + c.event.pauli, + loc.gate_type, + loc.qubits + .iter() + .map(pecos_core::QubitId::index) + .collect::>(), + combo.effect.dem_outputs, + ); + } + } + (false, false) => w1_trivial += 1, + } + }); + + println!("\n--- Weight-1 faults ---"); + println!(" Total: {w1_total}"); + println!(" Detectable: {w1_detectable}"); + println!(" Undetectable: {w1_undetectable}"); + println!(" Trivial: {w1_trivial}"); + + // The [[4,2,2]] code starting from |0000⟩ has partial detection: + // - Z stabilizer detects X errors from round 1 (|0000⟩ is Z eigenstate) + // - X stabilizer only detects Z errors from round 2 onward (round-to-round) + // - First-round Z errors on data qubits are undetectable (no X-stabilizer + // detector in round 1, since |0000⟩ is not an X-stabilizer eigenstate) + assert!(w1_total > 0, "Should have fault events"); + assert!(w1_detectable > 0, "Some faults should be detectable"); + assert!(w1_undetectable > 0, "First-round Z faults are undetectable"); +} + +#[test] +fn code_422_ml_decoder() { + use pecos_qec::fault_tolerance::dem_builder::{DemSampler, NoiseConfig}; + use pecos_qec::fault_tolerance::lookup_decoder::LookupDecoder; + use rand::SeedableRng; + + let dag = build_422_code(2); + let noise = NoiseConfig::uniform(0.001); + + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + // Build ML decoder up to weight 2 + let decoder = LookupDecoder::build(&map, &noise, 2); + + println!("[[4,2,2]] ML Decoder:"); + println!(" Syndrome patterns: {}", decoder.num_syndromes()); + println!(" Observables: {}", decoder.num_observables()); + + // Build sampler + let sampler = DemSampler::from_circuit(&dag, &noise).unwrap(); + + // Sample and decode + let mut rng = rand::rngs::SmallRng::seed_from_u64(42); + let num_shots = 100_000; + let mut decoded_errors = 0usize; + let mut raw_errors = 0usize; + + let mut post_selected_shots = 0usize; + let mut post_selected_errors = 0usize; + + for _ in 0..num_shots { + if let Some(dual) = sampler.sample_dual(&mut rng) { + let result = decoder.decode_from_bools(&dual.detector_events); + + // ML correction + let has_residual = dual + .dem_output_flips + .iter() + .zip(&result.corrections) + .any(|(&flip, &corr)| flip ^ corr); + + if has_residual { + decoded_errors += 1; + } + if dual.dem_output_flips.iter().any(|&f| f) { + raw_errors += 1; + } + + // Post-selection: only keep shots with no detectors fired + if !result.detected { + post_selected_shots += 1; + if dual.dem_output_flips.iter().any(|&f| f) { + post_selected_errors += 1; + } + } + } + } + + let raw_rate = raw_errors as f64 / num_shots as f64; + let decoded_rate = decoded_errors as f64 / num_shots as f64; + let ps_rate = if post_selected_shots > 0 { + post_selected_errors as f64 / post_selected_shots as f64 + } else { + 0.0 + }; + let discard_rate = 1.0 - post_selected_shots as f64 / num_shots as f64; + + println!("\n Shots: {num_shots}"); + println!(" Raw observable rate: {raw_rate:.6}"); + println!(" ML decoded rate: {decoded_rate:.6}"); + println!(" Post-selected rate: {ps_rate:.6} (discarded {discard_rate:.4})"); + println!( + " PS improvement: {:.1}x", + raw_rate / ps_rate.max(1e-10) + ); + + // For the [[4,2,2]] detection code, post-selection should improve the + // observable error rate: detected errors are discarded, only undetectable + // errors (weight 2+) remain. At p=0.001, this gives ~p^2 rate. + assert!( + ps_rate < raw_rate || post_selected_shots == 0, + "Post-selection should reduce observable error rate" + ); + assert!( + decoded_errors < num_shots, + "Some shots should decode correctly" + ); +} diff --git a/crates/pecos-qec/tests/idle_noise_tests.rs b/crates/pecos-qec/tests/idle_noise_tests.rs new file mode 100644 index 000000000..6eebe49d0 --- /dev/null +++ b/crates/pecos-qec/tests/idle_noise_tests.rs @@ -0,0 +1,213 @@ +// 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 idle-gate noise. `GateType::Idle` is a no-op unless +//! noise is explicitly attached to idle locations via dedicated idle noise or +//! per-gate idle rates. + +use pecos_core::{QubitId, TimeUnits}; +use pecos_qec::fault_tolerance::dem_builder::{ + DemBuilder, DemSamplerBuilder, NoiseConfig, PerGateTypeNoise, +}; +use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; +use pecos_quantum::{DagCircuit, GateType}; + +fn build_idle_then_measure(num_idles: usize) -> DagCircuit { + // Prep N qubits, idle each once, measure each. Very simple fixture + // to isolate idle-gate contributions. + let mut dag = DagCircuit::new(); + for q in 0..num_idles { + dag.pz(&[q]); + } + for q in 0..num_idles { + dag.idle(TimeUnits::new(100), &[q]); + } + for q in 0..num_idles { + dag.mz(&[q]); + } + dag +} + +#[test] +fn idle_locations_contribute_mechanisms_when_rates_set() { + let dag = build_idle_then_measure(2); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + // No noise elsewhere; idle rates set only on qubit 0. + let q0 = QubitId::from(0usize); + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)) + .with_1q_rates_for_qubit(GateType::Idle, q0, [0.001, 0.001, 0.001]); + let sim = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + // Exactly one location contributes noise (idle on q0). That location + // produces X, Y, Z mechanisms, of which X+Y generally both flip the + // Z-basis measurement, but aggregation collapses them. Expect at + // least one mechanism -> we used to get zero silently. + assert!( + sim.num_mechanisms() > 0, + "idle on q0 should produce at least one mechanism", + ); +} + +#[test] +fn idle_rates_absent_means_no_idle_contribution() { + // Config provides no Idle rates and uses zero base noise. DEM should have + // zero mechanisms: prep/measure are 0 and idle is a no-op by default. + let dag = build_idle_then_measure(3); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)); + let sim = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-3]}, {"id": 1, "records": [-2]}, {"id": 2, "records": [-1]}]"#) + .unwrap() + .build().unwrap(); + assert_eq!(sim.num_mechanisms(), 0); +} + +#[test] +fn per_gate_base_p1_does_not_attach_to_idle() { + let dag = build_idle_then_measure(2); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.01, 0.0, 0.0, 0.0)); + let sim = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + assert_eq!(sim.num_mechanisms(), 0); +} + +#[test] +fn per_gate_base_idle_noise_attaches_to_idle() { + let dag = build_idle_then_measure(2); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::with_idle(0.01, 0.0, 0.0, 0.0, 0.002)); + let sim = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + assert!( + sim.num_mechanisms() > 0, + "base p_idle in per-gate config should attach to idle locations", + ); +} + +#[test] +fn idle_noise_respects_per_qubit_override() { + // q0 gets boosted idle rate; q1 gets zero. Expect exactly one + // mechanism from q0's idle. + let dag = build_idle_then_measure(2); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let q0 = QubitId::from(0usize); + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)) + .with_1q_rates(GateType::Idle, [0.0, 0.0, 0.0]) + .with_1q_rates_for_qubit(GateType::Idle, q0, [0.01, 0.01, 0.01]); + let sim = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + assert!(sim.num_mechanisms() > 0); + // q1's idle at zero rates should not contribute -- only q0's. + assert!(sim.max_error_probability() >= 0.01 * 0.5); +} + +#[test] +fn idle_with_scalar_p1_is_noop() { + // Ordinary p1 gate noise should not attach to Idle. Idle is a no-op unless + // idle noise is explicitly configured. + let dag = build_idle_then_measure(2); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let sim = DemSamplerBuilder::new(&influence) + .with_noise(0.01, 0.0, 0.0, 0.0) + .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + assert_eq!(sim.num_mechanisms(), 0); +} + +#[test] +fn explicit_uniform_idle_noise_is_noisy() { + let dag = build_idle_then_measure(2); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let sim = DemSamplerBuilder::new(&influence) + .with_noise_config(NoiseConfig::with_idle(0.01, 0.0, 0.0, 0.0, 0.002)) + .with_detectors_json(r#"[{"id": 0, "records": [-2]}, {"id": 1, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + assert!( + sim.num_mechanisms() > 0, + "explicit p_idle should produce idle-location mechanisms", + ); +} + +#[test] +fn dem_builder_scalar_p1_does_not_attach_to_idle() { + let dag = build_idle_then_measure(1); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let dem = DemBuilder::new(&influence) + .with_noise(0.01, 0.0, 0.0, 0.0) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + + assert_eq!(dem.num_contributions(), 0); +} + +#[test] +fn dem_builder_explicit_idle_noise_is_noisy() { + let dag = build_idle_then_measure(1); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let dem = DemBuilder::new(&influence) + .with_noise_config(NoiseConfig::with_idle(0.01, 0.0, 0.0, 0.0, 0.002)) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + + assert!( + dem.num_contributions() > 0, + "explicit p_idle should produce idle-location DEM contributions", + ); +} diff --git a/crates/pecos-qec/tests/mem_stab_tests.rs b/crates/pecos-qec/tests/mem_stab_tests.rs new file mode 100644 index 000000000..57a7a072e --- /dev/null +++ b/crates/pecos-qec/tests/mem_stab_tests.rs @@ -0,0 +1,142 @@ +// 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 `MemStabSim`. +//! +//! Parity: `MemStabSim` must produce identical raw-measurement shots to the raw +//! `DagFaultAnalyzer` + `MemBuilder` + `MeasurementNoiseModel` pipeline given equal +//! inputs and seeds. + +use pecos_qec::fault_tolerance::dem_builder::{MemBuilder, NoiseConfig}; +use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; +use pecos_qec::mem_stab::{MemStabError, MemStabSim}; +use pecos_quantum::DagCircuit; +use rand::SeedableRng; +use rand::rngs::SmallRng; + +fn repetition_code_circuit() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[3]); + dag.pz(&[4]); + dag.cx(&[(0, 3)]); + dag.cx(&[(1, 3)]); + dag.cx(&[(1, 4)]); + dag.cx(&[(2, 4)]); + dag.mz(&[3]); + dag.mz(&[4]); + dag +} + +#[test] +fn builder_rejects_missing_circuit() { + let err = MemStabSim::builder().build().unwrap_err(); + assert!(matches!(err, MemStabError::MissingCircuit)); +} + +#[test] +fn zero_noise_produces_zero_mechanisms() { + let sim = MemStabSim::builder() + .circuit(repetition_code_circuit()) + .noise(NoiseConfig::uniform(0.0)) + .build() + .unwrap(); + + assert_eq!(sim.num_mechanisms(), 0); + assert_eq!(sim.num_measurements(), 2); +} + +#[test] +fn parity_with_raw_pipeline() { + let noise = NoiseConfig::uniform(0.01); + let shots = 512; + let seed = 0xFEED_FACE_u64; + + // Path 1: MemStabSim. + let sim = MemStabSim::builder() + .circuit(repetition_code_circuit()) + .noise(noise.clone()) + .build() + .unwrap(); + let mut rng1 = SmallRng::seed_from_u64(seed); + let batch1 = sim.sample_batch(shots, &mut rng1); + + // Path 2: raw pipeline, identical inputs + seed. + let dag = repetition_code_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence_map = analyzer.build_influence_map(); + let mnm = MemBuilder::new(&influence_map) + .with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep) + .build(); + let mut rng2 = SmallRng::seed_from_u64(seed); + let mut batch2 = Vec::with_capacity(shots); + let mut buf = vec![false; mnm.num_measurements]; + for _ in 0..shots { + mnm.sample_into(&mut buf, &mut rng2); + batch2.push(buf.clone()); + } + + assert_eq!(batch1, batch2); +} + +#[test] +fn sample_and_sample_batch_agree() { + let sim = MemStabSim::builder() + .circuit(repetition_code_circuit()) + .noise(NoiseConfig::uniform(0.02)) + .build() + .unwrap(); + + let seed = 0xABCD_EF01_u64; + let shots = 32; + + let mut rng_single = SmallRng::seed_from_u64(seed); + let singles: Vec> = (0..shots).map(|_| sim.sample(&mut rng_single)).collect(); + + let mut rng_batch = SmallRng::seed_from_u64(seed); + let batch = sim.sample_batch(shots, &mut rng_batch); + + assert_eq!(singles, batch); +} + +#[test] +fn shot_shape_is_correct() { + let sim = MemStabSim::builder() + .circuit(repetition_code_circuit()) + .noise(NoiseConfig::uniform(0.005)) + .build() + .unwrap(); + + let mut rng = SmallRng::seed_from_u64(11); + let batch = sim.sample_batch(20, &mut rng); + assert_eq!(batch.len(), 20); + for row in &batch { + assert_eq!(row.len(), sim.num_measurements()); + } +} + +#[test] +fn nonzero_noise_yields_some_flips() { + let sim = MemStabSim::builder() + .circuit(repetition_code_circuit()) + .noise(NoiseConfig::uniform(0.1)) + .build() + .unwrap(); + + let mut rng = SmallRng::seed_from_u64(123); + let batch = sim.sample_batch(1000, &mut rng); + + let total_flips: usize = batch + .iter() + .map(|row| row.iter().filter(|&&b| b).count()) + .sum(); + assert!(total_flips > 0); +} diff --git a/crates/pecos-qec/tests/per_gate_dem_builder_tests.rs b/crates/pecos-qec/tests/per_gate_dem_builder_tests.rs new file mode 100644 index 000000000..0dbb4775f --- /dev/null +++ b/crates/pecos-qec/tests/per_gate_dem_builder_tests.rs @@ -0,0 +1,199 @@ +// 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 `DemBuilder::with_per_gate_noise`. Parity with +//! `DemSamplerBuilder` path + verification that decomposed DEM text +//! output reflects per-gate-type per-Pauli rates. + +use pecos_core::{QubitId, TimeUnits}; +use pecos_qec::fault_tolerance::dem_builder::{DemBuilder, NoiseConfig, PerGateTypeNoise}; +use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; +use pecos_quantum::{DagCircuit, GateType}; + +fn build_parity_check() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[2]); + dag.cx(&[(0, 2)]); + dag.cx(&[(1, 2)]); + dag.mz(&[2]); + dag +} + +#[test] +fn uniform_equivalent_per_gate_matches_scalar_dem() { + // DemBuilder with empty-map PerGateTypeNoise (base noise only) should + // produce the same DEM text as scalar `with_noise`. + let dag = build_parity_check(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let scalar = DemBuilder::new(&influence) + .with_noise(0.01, 0.02, 0.005, 0.003) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + + let per_gate = DemBuilder::new(&influence) + .with_per_gate_noise(PerGateTypeNoise::from_base_noise(NoiseConfig::new( + 0.01, 0.02, 0.005, 0.003, + ))) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + + // Both DEMs should contain the same mechanism set with matching probabilities. + assert_eq!(scalar.num_contributions(), per_gate.num_contributions()); + let scalar_text = scalar.to_string(); + let per_gate_text = per_gate.to_string(); + // Equal line counts in the text form (mechanism-by-mechanism identical). + assert_eq!( + scalar_text + .lines() + .filter(|l| l.starts_with("error(")) + .count(), + per_gate_text + .lines() + .filter(|l| l.starts_with("error(")) + .count(), + "scalar and uniform-per-gate should produce identical error-line counts:\nscalar:\n{scalar_text}\nper_gate:\n{per_gate_text}", + ); +} + +#[test] +fn per_gate_override_produces_decomposed_dem_text() { + // Specific per-gate CX rates should appear in the decomposed DEM text. + let dag = build_parity_check(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let mut rates_2q = [0.0; 15]; + // IX = index 0, nonzero probability + rates_2q[0] = 1e-3; + // XX = index 4 ("IX","IY","IZ","XI","XX",...) — high correlated rate + rates_2q[4] = 5e-3; + + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)) + .with_2q_rates(GateType::CX, rates_2q); + + let dem = DemBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + + let text = dem.to_string_decomposed(); + let error_lines = text.lines().filter(|l| l.starts_with("error(")).count(); + assert!( + error_lines > 0, + "expected per-gate CX rates to produce error lines in decomposed DEM:\n{text}", + ); +} + +#[test] +fn per_qubit_cx_override_changes_dem_probabilities() { + // Like the sampler-path test: boost CX (0, 2) per-qubit-pair, compare + // to baseline where only per-gate-type is set. + let dag = build_parity_check(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let q0 = QubitId::from(0usize); + let q2 = QubitId::from(2usize); + + let cfg_baseline = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)) + .with_2q_rates(GateType::CX, [1e-4; 15]); + let cfg_boost = cfg_baseline + .clone() + .with_2q_rates_for_qubits(GateType::CX, q0, q2, [1e-3; 15]); + + let baseline = DemBuilder::new(&influence) + .with_per_gate_noise(cfg_baseline) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + let boosted = DemBuilder::new(&influence) + .with_per_gate_noise(cfg_boost) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + + // Both produce the same mechanism set (same circuit structure) but + // the boosted DEM should have higher-average probabilities in its text. + assert_eq!(baseline.num_contributions(), boosted.num_contributions()); + + // Parse error-line probabilities and sum them. + let sum_probs = |s: &str| -> f64 { + s.lines() + .filter_map(|l| l.strip_prefix("error(")) + .filter_map(|inner| inner.split(')').next()) + .filter_map(|p| p.parse::().ok()) + .sum() + }; + let baseline_sum = sum_probs(&baseline.to_string()); + let boosted_sum = sum_probs(&boosted.to_string()); + assert!( + boosted_sum > 2.0 * baseline_sum, + "per-qubit-pair boost should raise probability sum: baseline={baseline_sum} boosted={boosted_sum}", + ); +} + +#[test] +fn idle_locations_contribute_to_dem_text() { + // Circuit with an idle gate + per-qubit idle rate should produce an + // error line for the idle location. Before the Idle routing fix this + // test would see zero idle contributions. + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.idle(TimeUnits::new(100), &[0]); + dag.mz(&[0]); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let q0 = QubitId::from(0usize); + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)) + .with_1q_rates_for_qubit(GateType::Idle, q0, [0.01, 0.01, 0.01]); + + let dem = DemBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + + let text = dem.to_string(); + assert!( + text.contains("error("), + "expected idle location to produce an error line:\n{text}", + ); +} + +#[test] +fn decomposed_dem_reflects_per_gate_noise() { + // DemBuilder's decomposed path uses mark_graphlike_decomposable and + // Y-decomposition logic. Verify the text output includes both + // direct and decomposed-effect lines when per-gate noise is set. + let dag = build_parity_check(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.005, 0.005, 0.005, 0.005)); + let dem = DemBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build(); + + // Both formats should have content. + let non_decomposed = dem.to_string(); + let decomposed = dem.to_string_decomposed(); + assert!(non_decomposed.contains("error(")); + assert!(decomposed.contains("error(")); +} diff --git a/crates/pecos-qec/tests/per_gate_noise_tests.rs b/crates/pecos-qec/tests/per_gate_noise_tests.rs new file mode 100644 index 000000000..9726c52ad --- /dev/null +++ b/crates/pecos-qec/tests/per_gate_noise_tests.rs @@ -0,0 +1,154 @@ +// 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-gate-type noise path on +//! [`DemSamplerBuilder`]. +//! +//! Verifies: +//! 1. Uniform-equivalent per-gate spec produces identical mechanisms to +//! the scalar `with_noise` path. +//! 2. Per-gate rates actually override scalar rates for gates in the map. +//! 3. Fallback uniform rates apply to gate types not in the map. + +use pecos_qec::fault_tolerance::dem_builder::{DemSamplerBuilder, NoiseConfig, PerGateTypeNoise}; +use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; +use pecos_quantum::{DagCircuit, GateType}; + +fn build_parity_check_circuit() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[2]); + dag.cx(&[(0, 2)]); + dag.cx(&[(1, 2)]); + dag.mz(&[2]); + dag +} + +#[test] +fn per_gate_uniform_equivalent_matches_scalar_path() { + // Build a PerGateTypeNoise that mimics uniform p1=p2=p_meas=p_prep=0.01. + // Expectation: `per_gate.rate_1q()` lookup with an empty map uses + // `base.p1 / 3.0` for 1Q, and `base.p2 / 15.0` for 2Q -- + // which is exactly what the legacy scalar path uses. So the two + // builders should produce identical mechanism sets. + let dag = build_parity_check_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let p = 0.01; + let scalar = DemSamplerBuilder::new(&influence) + .with_noise(p, p, p, p) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + let per_gate = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(p))) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + assert_eq!( + scalar.num_mechanisms(), + per_gate.num_mechanisms(), + "uniform-equivalent per-gate must produce same mechanism count as scalar", + ); +} + +#[test] +fn per_gate_override_changes_cx_rate() { + // Assign a large explicit CX rate via per_gate, compare against a + // scalar baseline with small p2. + let dag = build_parity_check_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + // Scalar baseline: small p2. + let small = DemSamplerBuilder::new(&influence) + .with_noise(0.0, 1e-4, 0.0, 0.0) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + // Per-gate override: same p2 for CX via map, but 10x larger value. + let per_gate = DemSamplerBuilder::new(&influence) + .with_per_gate_noise( + PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_2q_rates(GateType::CX, [1e-3; 15]), + ) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + // Per-gate path should produce the same mechanism count (same circuit + // structure), but larger aggregate error probabilities. + assert_eq!(small.num_mechanisms(), per_gate.num_mechanisms()); + assert!( + per_gate.average_error_probability() > 5.0 * small.average_error_probability(), + "10x larger per-CX rate should produce substantially larger avg error \ + (got per_gate={}, scalar={})", + per_gate.average_error_probability(), + small.average_error_probability(), + ); +} + +#[test] +fn per_gate_base_noise_used_for_unmapped_gate_types() { + // Specify H explicitly (rates[X, Y, Z]); CX uses the base noise model. + let dag = build_parity_check_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.01)) + .with_1q_rates(GateType::H, [0.001, 0.001, 0.001]); + + let sim = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + // Parity-check circuit has no H gate -- all 1Q contributions come + // from prep/measurement. Just verify it builds and has mechanisms. + assert!(sim.num_mechanisms() > 0); +} + +#[test] +fn per_gate_asymmetric_2q_rates() { + // Set only lambda_IX != 0, everything else zero for CX. Confirms the + // sparse rate path (only one of 15 pair rates nonzero) works. + let dag = build_parity_check_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let mut rates_2q = [0.0; 15]; + rates_2q[0] = 0.005; // IX + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_2q_rates(GateType::CX, rates_2q); + + let sim = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + // With everything else zero, should still produce some mechanisms + // (from the single IX contribution at each CX location). + assert!( + sim.num_mechanisms() > 0, + "sparse rates should still produce mechanisms from the IX contribution", + ); +} diff --git a/crates/pecos-qec/tests/per_qubit_measurement_tests.rs b/crates/pecos-qec/tests/per_qubit_measurement_tests.rs new file mode 100644 index 000000000..aa95c9435 --- /dev/null +++ b/crates/pecos-qec/tests/per_qubit_measurement_tests.rs @@ -0,0 +1,166 @@ +// 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 per-qubit measurement and preparation rates on +//! [`PerGateTypeNoise`]. Mirrors the per-qubit gate tests but for MZ/PZ +//! locations. + +use pecos_core::QubitId; +use pecos_qec::fault_tolerance::dem_builder::{DemSamplerBuilder, NoiseConfig, PerGateTypeNoise}; +use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; +use pecos_quantum::DagCircuit; + +#[test] +fn per_qubit_measurement_override_takes_precedence() { + // Unit test the lookup layering. + let q0 = QubitId::from(0usize); + let q1 = QubitId::from(1usize); + let mut cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.01)); + // Explicitly override q0, leave q1 on the base noise model. + cfg = cfg.with_measurement_rate(q0, 0.1); + assert!((cfg.measurement_rate_on(q0) - 0.1).abs() < 1e-14); + assert!((cfg.measurement_rate_on(q1) - 0.01).abs() < 1e-14); +} + +#[test] +fn per_qubit_init_override_takes_precedence() { + let q0 = QubitId::from(0usize); + let q1 = QubitId::from(1usize); + let mut cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.02)); + cfg = cfg.with_init_rate(q0, 0.2); + assert!((cfg.init_rate_on(q0) - 0.2).abs() < 1e-14); + assert!((cfg.init_rate_on(q1) - 0.02).abs() < 1e-14); +} + +fn build_three_ancilla_circuit() -> DagCircuit { + // Three prep + measure operations on three different qubits. Each + // qubit is only touched once so per-qubit rates affect exactly one + // mechanism each. + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.pz(&[1]); + dag.pz(&[2]); + dag.mz(&[0]); + dag.mz(&[1]); + dag.mz(&[2]); + dag +} + +#[test] +fn per_qubit_measurement_rate_raises_only_targeted_qubit() { + // With per-qubit override on qubit 0, and scalar 0 for everyone else, + // the total mechanism probability should reflect only the q0 + // contribution. + let dag = build_three_ancilla_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let q0 = QubitId::from(0usize); + let cfg_only_q0 = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)) + .with_measurement_rate(q0, 0.05); + let sim_only_q0 = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg_only_q0) + .with_detectors_json(r#"[{"id": 0, "records": [-3]}, {"id": 1, "records": [-2]}, {"id": 2, "records": [-1]}]"#) + .unwrap() + .build().unwrap(); + + // Baseline: all three qubits at the same rate. + let cfg_uniform = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.05, 0.0)); + let sim_uniform = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg_uniform) + .with_detectors_json(r#"[{"id": 0, "records": [-3]}, {"id": 1, "records": [-2]}, {"id": 2, "records": [-1]}]"#) + .unwrap() + .build().unwrap(); + + // Only q0 has meas noise in `sim_only_q0` => one mechanism. + // Uniform has meas noise on all three => three mechanisms. + // `average_error_probability` is per-mechanism, so it's the same + // in both; what differs is the count. + assert_eq!( + sim_only_q0.num_mechanisms(), + 1, + "expected exactly one mechanism for per-qubit q0-only override", + ); + assert_eq!( + sim_uniform.num_mechanisms(), + 3, + "expected three mechanisms when all qubits share the rate", + ); + // Per-mechanism probability should be the same 0.05 in both cases. + let delta = + (sim_only_q0.average_error_probability() - sim_uniform.average_error_probability()).abs(); + assert!( + delta < 1e-12, + "per-mech probabilities should match: {delta}" + ); +} + +#[test] +fn per_qubit_init_rate_raises_only_targeted_qubit() { + let dag = build_three_ancilla_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let q1 = QubitId::from(1usize); + let cfg_only_q1 = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.0)) + .with_init_rate(q1, 0.05); + let sim_only_q1 = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg_only_q1) + .with_detectors_json(r#"[{"id": 0, "records": [-3]}, {"id": 1, "records": [-2]}, {"id": 2, "records": [-1]}]"#) + .unwrap() + .build().unwrap(); + + let cfg_uniform = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.0, 0.05)); + let sim_uniform = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg_uniform) + .with_detectors_json(r#"[{"id": 0, "records": [-3]}, {"id": 1, "records": [-2]}, {"id": 2, "records": [-1]}]"#) + .unwrap() + .build().unwrap(); + + assert_eq!( + sim_only_q1.num_mechanisms(), + 1, + "expected exactly one mechanism for per-qubit q1-only init override", + ); + assert_eq!( + sim_uniform.num_mechanisms(), + 3, + "expected three mechanisms when all qubits share the rate", + ); +} + +#[test] +fn per_qubit_measurement_path_uses_base_rate_without_overrides() { + // A PerGateTypeNoise without any per-qubit measurement rates should + // use scalar p_meas exactly. + let dag = build_three_ancilla_circuit(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.05)); + let sim_per_gate = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-3]}, {"id": 1, "records": [-2]}, {"id": 2, "records": [-1]}]"#) + .unwrap() + .build().unwrap(); + + let sim_scalar = DemSamplerBuilder::new(&influence) + .with_noise(0.05, 0.05, 0.05, 0.05) + .with_detectors_json(r#"[{"id": 0, "records": [-3]}, {"id": 1, "records": [-2]}, {"id": 2, "records": [-1]}]"#) + .unwrap() + .build().unwrap(); + + assert_eq!(sim_per_gate.num_mechanisms(), sim_scalar.num_mechanisms()); + let delta = + (sim_per_gate.average_error_probability() - sim_scalar.average_error_probability()).abs(); + assert!(delta < 1e-12, "delta {delta} should be near zero"); +} diff --git a/crates/pecos-qec/tests/per_qubit_noise_tests.rs b/crates/pecos-qec/tests/per_qubit_noise_tests.rs new file mode 100644 index 000000000..0be609f9c --- /dev/null +++ b/crates/pecos-qec/tests/per_qubit_noise_tests.rs @@ -0,0 +1,158 @@ +// 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 per-qubit noise variation on top of +//! [`PerGateTypeNoise`]. Verifies: +//! +//! 1. per-qubit rates override per-gate-type defaults for matching qubits. +//! 2. qubits not in the per-qubit map use the per-gate-type default. +//! 3. lookup lookup methods (`rate_1q_on`, `rate_2q_on`) layer correctly. + +use pecos_core::QubitId; +use pecos_qec::fault_tolerance::dem_builder::{DemSamplerBuilder, NoiseConfig, PerGateTypeNoise}; +use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; +use pecos_quantum::{DagCircuit, GateType}; + +fn assert_rate_eq(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < 1e-14, + "expected rate {expected:.16e}, got {actual:.16e}" + ); +} + +#[test] +fn per_qubit_override_takes_precedence_over_per_gate_type() { + // Direct unit test of the lookup layering, independent of DemSampler. + let q0 = QubitId::from(0usize); + let q1 = QubitId::from(1usize); + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.1)) + .with_1q_rates(GateType::H, [0.01, 0.02, 0.03]) + .with_1q_rates_for_qubit(GateType::H, q0, [0.001, 0.002, 0.003]); + + // qubit 0 has per-qubit override + assert_rate_eq(cfg.rate_1q_on(GateType::H, q0, 0), 0.001); + assert_rate_eq(cfg.rate_1q_on(GateType::H, q0, 1), 0.002); + assert_rate_eq(cfg.rate_1q_on(GateType::H, q0, 2), 0.003); + + // qubit 1 uses the per-gate-type default. + assert_rate_eq(cfg.rate_1q_on(GateType::H, q1, 0), 0.01); + assert_rate_eq(cfg.rate_1q_on(GateType::H, q1, 1), 0.02); + + // Unregistered gate on qubit 0 uses the per-gate-type default (not set), + // then to uniform base.p1/3. + let uniform_share = 0.1_f64 / 3.0; + assert!((cfg.rate_1q_on(GateType::X, q0, 0) - uniform_share).abs() < 1e-14); +} + +#[test] +fn per_qubit_2q_override_takes_precedence() { + let q0 = QubitId::from(0usize); + let q1 = QubitId::from(1usize); + let q2 = QubitId::from(2usize); + let q3 = QubitId::from(3usize); + + let mut per_pair = [0.0; 15]; + per_pair[0] = 1e-3; // IX + let mut gate_default = [0.0; 15]; + gate_default[0] = 5e-4; // IX + + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_2q_rates(GateType::CX, gate_default) + .with_2q_rates_for_qubits(GateType::CX, q0, q1, per_pair); + + // (q0, q1) uses the specific rates + assert_rate_eq(cfg.rate_2q_on(GateType::CX, q0, q1, 0), 1e-3); + // (q2, q3) uses the per-gate-type default. + assert_rate_eq(cfg.rate_2q_on(GateType::CX, q2, q3, 0), 5e-4); + // Different ordered pair (q1, q0): NOT the same as (q0, q1). Falls back. + assert_rate_eq(cfg.rate_2q_on(GateType::CX, q1, q0, 0), 5e-4); +} + +fn build_circuit_with_two_cxs() -> DagCircuit { + // Two CX gates on different qubit pairs. The per-qubit override + // should apply only to the first CX. + let mut dag = DagCircuit::new(); + dag.pz(&[4]); + dag.cx(&[(0, 4)]); + dag.cx(&[(1, 4)]); + dag.mz(&[4]); + dag +} + +#[test] +fn per_qubit_cx_rate_affects_mechanism_probabilities() { + // Two CX locations touching different qubit pairs: + // CX on (0, 4): per-qubit rate (10x baseline) + // CX on (1, 4): per-gate-type default rate + // Total aggregated error probability should reflect the override. + let dag = build_circuit_with_two_cxs(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let q0 = QubitId::from(0usize); + let q4 = QubitId::from(4usize); + + let baseline_rates = [1e-4; 15]; + let boosted_rates = [1e-3; 15]; + + // Control case: just the baseline. + let baseline_cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_2q_rates(GateType::CX, baseline_rates); + let baseline = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(baseline_cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + // Override case: boost the (0, 4) pair specifically. + let override_cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_2q_rates(GateType::CX, baseline_rates) + .with_2q_rates_for_qubits(GateType::CX, q0, q4, boosted_rates); + let overridden = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(override_cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + assert_eq!(baseline.num_mechanisms(), overridden.num_mechanisms()); + // Override should substantially raise average error probability + // (one of two CX contributions now 10x the baseline). + let avg_base = baseline.average_error_probability(); + let avg_over = overridden.average_error_probability(); + assert!( + avg_over > 2.0 * avg_base, + "expected per-qubit override to raise avg error >>2x, got base={avg_base} over={avg_over}", + ); +} + +#[test] +fn per_qubit_path_uses_per_gate_type_rates_without_overrides() { + // A config with only per-gate-type rates (no per-qubit overrides) + // should still produce mechanisms from the per-gate-type rates. + let dag = build_circuit_with_two_cxs(); + let analyzer = DagFaultAnalyzer::new(&dag); + let influence = analyzer.build_influence_map(); + + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::uniform(0.0)) + .with_2q_rates(GateType::CX, [1e-3; 15]); + let sim = DemSamplerBuilder::new(&influence) + .with_per_gate_noise(cfg) + .with_detectors_json(r#"[{"id": 0, "records": [-1]}]"#) + .unwrap() + .build() + .unwrap(); + + assert!(sim.num_mechanisms() > 0); + assert!(sim.average_error_probability() > 0.0); +} diff --git a/crates/pecos-qec/tests/stim_dem_export_tests.rs b/crates/pecos-qec/tests/stim_dem_export_tests.rs new file mode 100644 index 000000000..6c6663e6c --- /dev/null +++ b/crates/pecos-qec/tests/stim_dem_export_tests.rs @@ -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. + +//! Integration tests for Stim-format DEM export from `DemStabSim` with +//! per-gate noise. Closes the +//! `~/Repos/pecos-docs/ideas/stim-compat-dem-export.md` gap. + +use pecos_core::QubitId; +use pecos_qec::dem_stab::DemStabSim; +use pecos_qec::fault_tolerance::dem_builder::{ + DemOutput, DetectorDef, NoiseConfig, PerGateTypeNoise, +}; +use pecos_quantum::{DagCircuit, GateType}; + +fn build_parity_check() -> DagCircuit { + let mut dag = DagCircuit::new(); + dag.pz(&[2]); + dag.cx(&[(0, 2)]); + dag.cx(&[(1, 2)]); + dag.mz(&[2]); + dag +} + +#[test] +fn dem_text_export_with_scalar_noise() { + let dag = build_parity_check(); + let sim = DemStabSim::builder() + .circuit(dag) + .noise(NoiseConfig::uniform(0.01)) + .detectors(vec![DetectorDef::new(0).with_records([-1])]) + .observables(vec![DemOutput::new(0).with_records([-1])]) + .build() + .unwrap(); + + let dem = sim.detector_error_model(); + let text = dem.to_string(); + + // Must contain at least one error mechanism. + assert!( + text.contains("error("), + "expected 'error(' line in DEM text:\n{text}" + ); + // Must declare the detector and observable. + assert!(text.contains("detector D0"), "missing detector D0:\n{text}"); + assert!( + text.contains("logical_observable L0") || text.contains("observable_include L0"), + "missing observable decl:\n{text}", + ); +} + +#[test] +fn dem_text_export_with_per_gate_noise() { + let dag = build_parity_check(); + let q0 = QubitId::from(0usize); + let q2 = QubitId::from(2usize); + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 0.001, 0.001)) + .with_2q_rates(GateType::CX, [1e-3; 15]) + .with_2q_rates_for_qubits(GateType::CX, q0, q2, [5e-3; 15]); + let sim = DemStabSim::builder() + .circuit(dag) + .per_gate_noise(cfg) + .detectors(vec![DetectorDef::new(0).with_records([-1])]) + .build() + .unwrap(); + + let dem = sim.detector_error_model(); + let text = dem.to_string(); + + // Should render multiple error mechanisms (CX on (0,2) boosted vs CX on (1,2)). + let error_lines = text.matches("error(").count(); + assert!( + error_lines > 0, + "expected per-gate-noise path to produce error lines:\n{text}", + ); + assert!(text.contains("detector D0")); +} + +#[test] +fn dem_round_trip_mechanism_count_matches_sampler() { + let dag = build_parity_check(); + let sim = DemStabSim::builder() + .circuit(dag) + .noise(NoiseConfig::uniform(0.005)) + .detectors(vec![DetectorDef::new(0).with_records([-1])]) + .build() + .unwrap(); + + let dem = sim.detector_error_model(); + // The reconstructed DEM should have the same mechanism count as the + // sampler (direct contributions). + assert_eq!( + dem.num_contributions(), + sim.num_mechanisms(), + "mechanism count should round-trip between sampler and reconstructed DEM" + ); +} + +#[test] +fn dem_probabilities_recoverable_from_thresholds() { + // Check that the prob → u64 threshold → prob round-trip is close. + // Use a small, well-separated set of mechanisms. + let dag = build_parity_check(); + let sim = DemStabSim::builder() + .circuit(dag) + .noise(NoiseConfig::uniform(0.01)) + .detectors(vec![DetectorDef::new(0).with_records([-1])]) + .build() + .unwrap(); + + let dem = sim.detector_error_model(); + let text = dem.to_string(); + // Probabilities recovered should be non-zero and appear in the text + // in some form. + for line in text.lines().filter(|l| l.starts_with("error(")) { + // Parse the prob inside "error(...)". + let inner = line.trim_start_matches("error(").split(')').next().unwrap(); + let p: f64 = inner.parse().unwrap(); + assert!(p > 0.0 && p < 1.0, "probability out of range: {p}"); + } +} diff --git a/crates/pecos-qec/tests/targeted_tests.rs b/crates/pecos-qec/tests/targeted_tests.rs new file mode 100644 index 000000000..610be7b27 --- /dev/null +++ b/crates/pecos-qec/tests/targeted_tests.rs @@ -0,0 +1,330 @@ +// 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. + +//! Targeted tests for specific features and bug fixes from the annotation/decoder session. + +use pecos_core::pauli::{X, Y, Z}; +use pecos_qec::fault_tolerance::InfluenceBuilder; +use pecos_qec::fault_tolerance::dem_builder::{NoiseConfig, PauliWeights}; +use pecos_qec::fault_tolerance::lookup_decoder::LookupDecoder; +use pecos_qec::fault_tolerance::propagator::{DagFaultAnalyzer, Pauli}; +use pecos_quantum::DagCircuit; + +// ============================================================================ +// Y anticommutation: Y commutes with Y +// ============================================================================ + +/// Verify that Y fault does NOT flip a detector when the propagated observable +/// is also Y on that qubit. {Y, Y} = 2I (commutes), not anticommutes. +#[test] +fn y_commutes_with_y() { + // Build a circuit where the backward-propagated observable has Y on a qubit. + // Backward propagation from MZ through H -> SZ -> H: + // MZ: Z -> backward H: X -> backward SZ: Y -> backward H: Y + // So at the first H location, the observable is Y on qubit 0. + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.h(&[0]); // observable is Y here (backward from MZ through H,SZ,H) + dag.sz(&[0]); // observable is X here (backward from MZ through H,SZ) + dag.h(&[0]); // observable is Z here (backward from MZ through H) + dag.mz(&[0]); + + let analyzer = DagFaultAnalyzer::new(&dag); + let map = analyzer.build_influence_map(); + + // Find the first H gate's after-location (node with lowest index) + // At this point the backward-propagated observable should have Y on qubit 0. + let h_locs: Vec<( + usize, + &pecos_qec::fault_tolerance::propagator::DagSpacetimeLocation, + )> = map + .locations + .iter() + .enumerate() + .filter(|(_, loc)| loc.gate_type == pecos_core::gate_type::GateType::H && !loc.before) + .collect(); + + assert!(h_locs.len() >= 2, "Should have at least 2 H locations"); + let (first_h_loc, _) = h_locs[0]; // first H (closest to prep) + + // Y fault at first H should NOT flip detector (Y commutes with Y) + let y_dets = map.get_detector_indices(first_h_loc, Pauli::Y as u8); + assert!( + y_dets.is_empty(), + "Y fault should not flip detectors when observable is Y on same qubit, got {y_dets:?}" + ); + // But X and Z faults SHOULD flip (both anticommute with Y) + let x_dets = map.get_detector_indices(first_h_loc, Pauli::X as u8); + let z_dets = map.get_detector_indices(first_h_loc, Pauli::Z as u8); + assert!( + !x_dets.is_empty() || !z_dets.is_empty(), + "X or Z fault should flip detectors when observable is Y" + ); +} + +// ============================================================================ +// T1/T2 idle noise +// ============================================================================ + +/// Verify T1/T2 produces biased noise: P(Z) > P(X) = P(Y). +#[test] +fn t1_t2_biased_idle_noise() { + let noise = NoiseConfig::new(0.001, 0.01, 0.001, 0.001).set_t1_t2(50_000.0, 30_000.0); + + // 1000 time units of idle + let pp = noise.idle_pauli_probs(1000.0); + + // P(X) == P(Y) (from amplitude damping) + assert!( + (pp.px - pp.py).abs() < 1e-15, + "P(X) should equal P(Y): px={}, py={}", + pp.px, + pp.py + ); + + // P(Z) > P(X) (dephasing dominates relaxation) + assert!( + pp.pz > pp.px, + "P(Z) should be larger than P(X): pz={}, px={}", + pp.pz, + pp.px + ); + + // Total should be reasonable (not > 1) + assert!(pp.total() < 1.0, "Total should be < 1: {}", pp.total()); + assert!(pp.total() > 0.0, "Total should be > 0"); +} + +/// Verify uniform depolarizing idle gives equal X/Y/Z. +#[test] +fn uniform_idle_noise() { + let noise = NoiseConfig::uniform(0.001); + let pp = noise.idle_pauli_probs(1.0); + + let eps = 1e-15; + assert!((pp.px - pp.py).abs() < eps); + assert!((pp.py - pp.pz).abs() < eps); + assert!((pp.px - 0.001 / 3.0).abs() < eps); +} + +// ============================================================================ +// PauliWeights +// ============================================================================ + +/// Verify custom single-qubit weights change decoder probabilities. +#[test] +fn custom_p1_weights_affect_decoder() { + let mut dag = DagCircuit::new(); + dag.pz(&[0, 1]); + dag.h(&[0]); + dag.cx(&[(0, 1)]); + let ms = dag.mz(&[0, 1]); + dag.observable(&[ms[0], ms[1]]); + + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + // Uniform weights + let noise_uniform = NoiseConfig::uniform(0.001); + let d_uniform = LookupDecoder::build(&map, &noise_uniform, 2); + + // Biased weights: Z only + let noise_biased = NoiseConfig::uniform(0.001).set_p1_weights(PauliWeights::from([ + (X(0), 0.0), + (Y(0), 0.0), + (Z(0), 1.0), + ])); + let d_biased = LookupDecoder::build(&map, &noise_biased, 2); + + // Both should build successfully + assert!(d_uniform.num_syndromes() > 0); + assert!(d_biased.num_syndromes() > 0); + + // Both should account for most probability at p=0.001 + assert!( + d_uniform.accounted_probability() > 0.99, + "Uniform: {}", + d_uniform.accounted_probability() + ); + assert!( + d_biased.accounted_probability() > 0.99, + "Biased: {}", + d_biased.accounted_probability() + ); +} + +/// Verify `PauliWeights` validates sum to ~1.0. +#[test] +#[should_panic(expected = "must sum to 1.0")] +fn pauli_weights_validation() { + // Should panic: doesn't sum to 1.0 + let _ = PauliWeights::from([(X(0), 0.5), (Y(0), 0.3)]); +} + +/// Verify `PauliWeights::weight_for` matches by pattern, not qubit ID. +#[test] +fn pauli_weights_pattern_matching() { + let w = PauliWeights::uniform_2q(); + + // X(0) & Z(1) pattern should match regardless of actual qubit IDs + let weight = w.weight_for(&(X(0) & Z(1))); + assert!((weight - 1.0 / 15.0).abs() < 1e-10); + + // Same pattern from different qubit IDs should also match + let weight2 = w.weight_for(&(X(5) & Z(9))); + assert!( + (weight2 - 1.0 / 15.0).abs() < 1e-10, + "Pattern matching should ignore qubit IDs: got {weight2}" + ); +} + +// ============================================================================ +// Prep-gate propagation stop +// ============================================================================ + +/// Verify that faults before a mid-circuit reset don't propagate past it. +#[test] +fn prep_gate_stops_propagation() { + // Circuit: PZ -> H -> PZ (reset) -> MZ + // An X fault after the first H should NOT affect the final measurement + // because the second PZ resets qubit 0. + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.h(&[0]); // fault here + dag.pz(&[0]); // mid-circuit reset -- should block propagation + dag.mz(&[0]); + + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + // Find the H gate's after-location + let mut h_has_influence = false; + for (loc_idx, loc) in map.locations.iter().enumerate() { + if loc.gate_type == pecos_core::gate_type::GateType::H && !loc.before { + let x_dets = map.influences.detectors(loc_idx, Pauli::X); + let z_dets = map.influences.detectors(loc_idx, Pauli::Z); + let y_dets = map.influences.detectors(loc_idx, Pauli::Y); + if !x_dets.is_empty() || !z_dets.is_empty() || !y_dets.is_empty() { + h_has_influence = true; + } + } + } + + assert!( + !h_has_influence, + "Faults before mid-circuit PZ should not propagate past the reset" + ); +} + +/// Verify that faults AFTER a mid-circuit reset DO affect later measurements. +#[test] +fn faults_after_reset_propagate() { + // Use DagFaultAnalyzer (which creates 1 detector per measurement) to + // verify that faults after a reset propagate to the measurement. + // Circuit: PZ -> PZ (reset) -> H -> MZ + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + dag.pz(&[0]); // mid-circuit reset + dag.h(&[0]); // fault here should affect measurement + dag.mz(&[0]); + + let analyzer = DagFaultAnalyzer::new(&dag); + let map = analyzer.build_influence_map(); + + // H gate after-location should have detector influence + // (backward from MZ through H: observable is X at H location) + let mut h_has_influence = false; + for (loc_idx, loc) in map.locations.iter().enumerate() { + if loc.gate_type == pecos_core::gate_type::GateType::H && !loc.before { + for p in [Pauli::X, Pauli::Y, Pauli::Z] { + let dets = map.influences.detectors(loc_idx, p); + if !dets.is_empty() { + h_has_influence = true; + } + } + } + } + + assert!( + h_has_influence, + "Faults after mid-circuit PZ should propagate to later measurements" + ); +} + +// ============================================================================ +// DagCircuit annotation methods +// ============================================================================ + +/// Verify `detector()` auto-derives Z Pauli from measurement nodes. +#[test] +fn detector_derives_pauli_from_measurements() { + let mut dag = DagCircuit::new(); + dag.pz(&[0, 1]); + dag.cx(&[(0, 1)]); + let ms = dag.mz(&[0, 1]); + dag.detector(&[ms[0], ms[1]]); + + let ann = &dag.annotations()[0]; + // Pauli should be Z on both measured qubits + let paulis = ann.pauli.paulis(); + assert_eq!(paulis.len(), 2); + assert_eq!(paulis[0].0, pecos_core::Pauli::Z); + assert_eq!(paulis[1].0, pecos_core::Pauli::Z); +} + +/// Verify `tracked_pauli` normalizes phase to +1. +#[test] +fn tracked_pauli_normalizes_phase() { + let mut dag = DagCircuit::new(); + dag.pz(&[0]); + + // -X(0) has phase -1 + let neg_x = -X(0); + assert_ne!(neg_x.get_phase(), pecos_core::QuarterPhase::PlusOne); + + dag.tracked_pauli(neg_x); + + // After storage, phase should be normalized to +1 + let ann = &dag.annotations()[0]; + assert_eq!(ann.pauli.get_phase(), pecos_core::QuarterPhase::PlusOne); +} + +/// Verify probability sums to 1.0 with the per-gate noise model. +#[test] +fn probability_sums_to_one() { + let mut dag = DagCircuit::new(); + dag.pz(&[0, 1]); + dag.h(&[0]); + dag.cx(&[(0, 1)]); + let ms = dag.mz(&[0, 1]); + dag.observable(&[ms[0], ms[1]]); + + let map = InfluenceBuilder::new(&dag) + .with_circuit_annotations(&dag) + .build(); + + let noise = NoiseConfig::uniform(0.001); + let decoder = LookupDecoder::build(&map, &noise, 3); + + assert!( + decoder.truncation_bound() < 1e-4, + "Weight-3 at p=0.001 should have small truncation: {}", + decoder.truncation_bound() + ); + assert!( + decoder.accounted_probability() > 0.999, + "Should account for >99.9% of probability: {}", + decoder.accounted_probability() + ); +} diff --git a/crates/pecos-qec/tests/unified_sampler_tests.rs b/crates/pecos-qec/tests/unified_sampler_tests.rs new file mode 100644 index 000000000..3bdaa2c57 --- /dev/null +++ b/crates/pecos-qec/tests/unified_sampler_tests.rs @@ -0,0 +1,483 @@ +// 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. + +//! Verification tests for `DemSampler`. +//! +//! These tests ensure the `DemSampler` matches both: +//! 1. `DemSampler` output (detector-level) for the same seed +//! 2. raw measurement output (measurement-level) for the same seed +//! 3. Statistical equivalence across large shot counts + +use pecos_qec::fault_tolerance::InfluenceBuilder; +use pecos_qec::fault_tolerance::dem_builder::DemSamplerBuilder; +use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; +use pecos_quantum::DagCircuit; +use pecos_random::PecosRng; + +/// Build a repetition code syndrome extraction circuit with the given +/// number of rounds. Data qubits: 0, 1, 2. Ancilla qubits: 3, 4. +fn repetition_code_circuit(num_rounds: usize) -> DagCircuit { + let mut dag = DagCircuit::new(); + for _ in 0..num_rounds { + dag.pz(&[3]); + dag.pz(&[4]); + dag.cx(&[(0, 3)]); + dag.cx(&[(1, 3)]); + dag.cx(&[(1, 4)]); + dag.cx(&[(2, 4)]); + dag.mz(&[3]); + dag.mz(&[4]); + } + dag +} + +/// Build influence map with logical Z on all data qubits. +fn build_influence_map( + circuit: &DagCircuit, +) -> pecos_qec::fault_tolerance::propagator::DagFaultInfluenceMap { + InfluenceBuilder::new(circuit).with_z(&[0, 1, 2]).build() +} + +// ============================================================================ +// Test 1: DemSampler::from_influence_map matches DemSampler statistics +// ============================================================================ + +#[test] +fn from_influence_map_produces_reasonable_statistics() { + let circuit = repetition_code_circuit(3); + let influence_map = build_influence_map(&circuit); + let seed = 42u64; + let num_shots = 50_000; + + let sampler = DemSamplerBuilder::new(&influence_map) + .with_noise(0.001, 0.01, 0.005, 0.001) + .raw_measurements() + .build() + .unwrap(); + let stats = sampler.sample_statistics(num_shots, seed); + + // At these noise levels, we should see some syndromes. The builder above + // uses `with_z`, which creates a tracked Pauli, not an observable, so + // logical-error statistics and DEM output columns stay empty. + assert!( + stats.syndrome_rate() > 0.0, + "Should have some syndromes at p~0.001-0.01" + ); + assert!( + stats.syndrome_rate() < 1.0, + "Should not have syndromes on every shot" + ); + assert_eq!(sampler.num_observables(), 0); + assert_eq!(sampler.num_tracked_paulis(), 1); + assert_eq!(stats.logical_error_count, 0); + assert!(stats.dem_output_counts().is_empty()); +} + +// ============================================================================ +// Test 2: MNM path produces valid measurement outcomes +// ============================================================================ + +#[test] +fn raw_sampler_produces_valid_measurement_outcomes() { + let circuit = repetition_code_circuit(2); + let influence_map = build_influence_map(&circuit); + + let sampler = DemSamplerBuilder::new(&influence_map) + .with_noise(0.001, 0.01, 0.005, 0.001) + .raw_measurements() + .build() + .unwrap(); + + let mut rng = PecosRng::seed_from_u64(42); + let (outcomes, _obs) = sampler.sample(&mut rng); + + assert_eq!(outcomes.len(), influence_map.measurements.len()); +} + +// ============================================================================ +// Test 3: Zero noise produces zero syndrome for both paths +// ============================================================================ + +#[test] +fn zero_noise_produces_zero_syndrome_both_paths() { + let circuit = repetition_code_circuit(3); + let influence_map = build_influence_map(&circuit); + + // DemSampler path + let sampler = DemSamplerBuilder::new(&influence_map) + .with_uniform_noise(0.0) + .raw_measurements() + .build() + .unwrap(); + let stats = sampler.sample_statistics(1000, 42); + assert_eq!( + stats.syndrome_count, 0, + "DemSampler: zero noise should give zero syndromes" + ); + assert_eq!( + stats.logical_error_count, 0, + "DemSampler: zero noise should give zero logical errors" + ); + + // DemSampler raw mode + let unified = DemSamplerBuilder::new(&influence_map) + .with_uniform_noise(0.0) + .raw_measurements() + .build() + .unwrap(); + let raw_stats = unified.sample_statistics(1000, 42); + assert_eq!( + raw_stats.syndrome_count, 0, + "Raw: zero noise should give zero syndromes" + ); +} + +// ============================================================================ +// Test 4: High noise produces high syndrome rate for both paths +// ============================================================================ + +#[test] +fn high_noise_produces_high_syndrome_rate_both_paths() { + let circuit = repetition_code_circuit(2); + let influence_map = build_influence_map(&circuit); + let num_shots = 10_000; + let p = 0.1; // 10% error rate — very noisy + + // DemSampler + let sampler = DemSamplerBuilder::new(&influence_map) + .with_uniform_noise(p) + .raw_measurements() + .build() + .unwrap(); + let stats = sampler.sample_statistics(num_shots, 42); + assert!( + stats.syndrome_rate() > 0.1, + "DemSampler: high noise should give high syndrome rate, got {}", + stats.syndrome_rate() + ); + + // DemSampler raw mode + let unified = DemSamplerBuilder::new(&influence_map) + .with_uniform_noise(p) + .raw_measurements() + .build() + .unwrap(); + let raw_stats = unified.sample_statistics(num_shots, 42); + assert!( + raw_stats.syndrome_rate() > 0.1, + "Raw: high noise should give high syndrome rate, got {}", + raw_stats.syndrome_rate() + ); +} + +// ============================================================================ +// Test 5: DemSampler detector mode matches DemSamplerBuilder statistics +// ============================================================================ + +#[test] +fn detector_mode_matches_dem_sampler_builder() { + let circuit = repetition_code_circuit(3); + let influence_map = build_influence_map(&circuit); + + let p1 = 0.001; + let p2 = 0.01; + let p_meas = 0.005; + let p_prep = 0.001; + let seed = 42u64; + let num_shots = 50_000; + + // Simple detector definitions: each measurement as its own detector + let num_meas = influence_map.measurements.len(); + let detector_records: Vec> = (0..num_meas) + .map(|i| vec![i32::try_from(i).unwrap()]) + .collect(); + let observable_records: Vec> = vec![]; + + // DemSamplerBuilder path + let dem_sampler = DemSamplerBuilder::new(&influence_map) + .with_noise(p1, p2, p_meas, p_prep) + .with_detector_records(detector_records.clone()) + .with_observable_records(observable_records.clone()) + .build() + .unwrap(); + let dem_stats = dem_sampler.sample_statistics(num_shots, seed); + + // DemSampler detector mode + let dem_sampler = DemSamplerBuilder::new(&influence_map) + .with_noise(p1, p2, p_meas, p_prep) + .with_detectors(detector_records, observable_records) + .build() + .unwrap(); + let raw_stats = dem_sampler.sample_statistics(num_shots, seed); + + // Same seed, same builder → identical statistics + assert_eq!( + dem_stats.total_shots, raw_stats.total_shots, + "Shot count mismatch" + ); + assert_eq!( + dem_stats.syndrome_count, raw_stats.syndrome_count, + "Syndrome count mismatch: DEM={}, Unified={}", + dem_stats.syndrome_count, raw_stats.syndrome_count + ); + assert_eq!( + dem_stats.logical_error_count, raw_stats.logical_error_count, + "Logical error count mismatch: DEM={}, Unified={}", + dem_stats.logical_error_count, raw_stats.logical_error_count + ); +} + +// ============================================================================ +// Test 6: DemSampler raw mode matches MNM measurement flip statistics +// ============================================================================ + +#[test] +fn raw_sampler_mode_matches_mnm_per_measurement() { + let circuit = repetition_code_circuit(2); + let influence_map = build_influence_map(&circuit); + + let p = 0.05; // moderate noise for visible statistics + let num_shots = 30_000; + + // DemSampler::from_influence_map path: count per-detector flips + let num_meas = influence_map.measurements.len(); + let dem = DemSamplerBuilder::new(&influence_map) + .with_uniform_noise(p) + .raw_measurements() + .build() + .unwrap(); + let mut dem_flip_counts = vec![0u64; num_meas]; + let mut rng = PecosRng::seed_from_u64(100); + for _ in 0..num_shots { + let (outcomes, _) = dem.sample(&mut rng); + for (i, &flipped) in outcomes.iter().enumerate() { + if flipped { + dem_flip_counts[i] += 1; + } + } + } + + // DemSampler raw mode: same exercise + let sampler = DemSamplerBuilder::new(&influence_map) + .with_uniform_noise(p) + .raw_measurements() + .build() + .unwrap(); + let mut unified_flip_counts = vec![0u64; num_meas]; + let mut rng2 = PecosRng::seed_from_u64(200); + for _ in 0..num_shots { + let (outputs, _) = sampler.sample(&mut rng2); + for (i, &flipped) in outputs.iter().enumerate() { + if flipped { + unified_flip_counts[i] += 1; + } + } + } + + // Compare per-measurement flip rates (different seeds -> statistical comparison) + for i in 0..num_meas { + // Flip counts are at most num_shots (30,000); precision loss is not a concern. + #[allow(clippy::cast_precision_loss)] + let dem_rate = dem_flip_counts[i] as f64 / f64::from(num_shots); + #[allow(clippy::cast_precision_loss)] + let unified_rate = unified_flip_counts[i] as f64 / f64::from(num_shots); + + // Allow generous tolerance since different seeds and non-det coin flips add noise + assert!( + (dem_rate - unified_rate).abs() < 0.1, + "Measurement {i} flip rate differs too much: DEM={dem_rate:.4}, Unified={unified_rate:.4}" + ); + } +} + +// ============================================================================ +// Test 7: DemSampler zero noise + detector mode = zero everything +// ============================================================================ + +#[test] +fn unified_zero_noise_detector_mode() { + let circuit = repetition_code_circuit(3); + let influence_map = build_influence_map(&circuit); + + let num_meas = influence_map.measurements.len(); + let detector_records: Vec> = (0..num_meas) + .map(|i| vec![i32::try_from(i).unwrap()]) + .collect(); + + let sampler = DemSamplerBuilder::new(&influence_map) + .with_uniform_noise(0.0) + .with_detectors(detector_records, vec![]) + .build() + .unwrap(); + + let stats = sampler.sample_statistics(1000, 42); + assert_eq!(stats.syndrome_count, 0); + assert_eq!(stats.logical_error_count, 0); +} + +// ============================================================================ +// Test 8: DemSampler batch output matches single-shot loop +// ============================================================================ + +#[test] +fn unified_batch_matches_single_shot_loop() { + let circuit = repetition_code_circuit(2); + let influence_map = build_influence_map(&circuit); + + let sampler = DemSamplerBuilder::new(&influence_map) + .with_uniform_noise(0.01) + .raw_measurements() + .build() + .unwrap(); + + let num_shots = 100; + let seed = 42u64; + + // Single-shot loop + let mut rng1 = PecosRng::seed_from_u64(seed); + let mut single_outputs = Vec::with_capacity(num_shots); + for _ in 0..num_shots { + let (out, _) = sampler.sample(&mut rng1); + single_outputs.push(out); + } + + // Batch + let mut rng2 = PecosRng::seed_from_u64(seed); + let (batch_outputs, _) = sampler.sample_batch(num_shots, &mut rng2); + + // Should match exactly (same seed, same engine) + // Note: non-det coin flips may differ between batch and single if + // the rng call order differs. This test verifies the mechanism-driven + // part matches. For measurements that are ALL deterministic (no non-det + // mask set), they should match exactly. + // For now, just check lengths match. + assert_eq!(single_outputs.len(), batch_outputs.len()); + assert_eq!(single_outputs[0].len(), batch_outputs[0].len()); +} + +// ============================================================================ +// Test 9: Linearly dependent detectors are rejected +// ============================================================================ + +#[test] +fn linearly_dependent_detectors_rejected() { + let circuit = repetition_code_circuit(2); + let influence_map = build_influence_map(&circuit); + + // Define 3 detectors where the third is XOR of the first two + // D0 = m[0], D1 = m[1], D2 = m[0] XOR m[1] = D0 XOR D1 → linearly dependent + let detector_records = vec![vec![0i32], vec![1], vec![0, 1]]; + let observable_records: Vec> = vec![]; + + let result = DemSamplerBuilder::new(&influence_map) + .with_noise(0.001, 0.01, 0.005, 0.001) + .with_detectors(detector_records, observable_records) + .build(); + + assert!( + result.is_err(), + "Should reject linearly dependent detector definitions" + ); + if let Err(e) = result { + let msg = format!("{e}"); + assert!( + msg.contains("linearly independent"), + "Error message should mention linear independence, got: {msg}" + ); + } +} + +// ============================================================================ +// Test 10: Valid independent detectors are accepted +// ============================================================================ + +#[test] +fn linearly_independent_detectors_accepted() { + let circuit = repetition_code_circuit(2); + let influence_map = build_influence_map(&circuit); + + // Two independent detectors: different single measurements + let detector_records = vec![vec![0i32], vec![1]]; + let observable_records: Vec> = vec![]; + + let result = DemSamplerBuilder::new(&influence_map) + .with_noise(0.001, 0.01, 0.005, 0.001) + .with_detectors(detector_records, observable_records) + .build(); + + assert!( + result.is_ok(), + "Should accept linearly independent detectors" + ); +} + +// ============================================================================ +// Test 11: In-circuit annotations → dual-output sampling +// ============================================================================ + +#[test] +fn circuit_annotation_dual_output() { + let mut dag = DagCircuit::new(); + + // 3 rounds for meaningful round-to-round detectors + let mut meas_nodes = Vec::new(); + for _ in 0..3 { + dag.pz(&[3, 4]); + dag.cx(&[(0, 3)]); + dag.cx(&[(1, 3)]); + dag.cx(&[(1, 4)]); + dag.cx(&[(2, 4)]); + let ms = dag.mz(&[3, 4]); + meas_nodes.push((ms[0].node, ms[1].node)); + } + + // Annotate round-to-round detectors (rounds 1-2 and 2-3) + dag.detector(&[meas_nodes[0].0, meas_nodes[1].0]); // q3 r1↔r2 + dag.detector(&[meas_nodes[0].1, meas_nodes[1].1]); // q4 r1↔r2 + dag.detector(&[meas_nodes[1].0, meas_nodes[2].0]); // q3 r2↔r3 + dag.detector(&[meas_nodes[1].1, meas_nodes[2].1]); // q4 r2↔r3 + + // Build MEASUREMENT-LEVEL influence map (DagFaultAnalyzer, not InfluenceBuilder) + // DagFaultAnalyzer creates one "detector" per raw measurement, so + // from_influence_map gives raw-measurement-level output suitable for + // user-defined detector XOR. + let analyzer = DagFaultAnalyzer::new(&dag); + let influence_map = analyzer.build_influence_map(); + + // Build sampler from annotations + let sampler = DemSamplerBuilder::new(&influence_map) + .with_uniform_noise(0.05) // high noise for visible effect + .with_circuit_annotations(&dag) + .build() + .unwrap(); + + assert!(sampler.num_mechanisms() > 0, "Should have mechanisms"); + + // Sample with dual output + let mut rng = PecosRng::seed_from_u64(42); + let mut det_fired = 0; + let num_shots = 10_000; + for _ in 0..num_shots { + if let Some(result) = sampler.sample_dual(&mut rng) + && result.detector_events.iter().any(|&d| d) + { + det_fired += 1; + } + } + + let rate = f64::from(det_fired) / f64::from(num_shots); + assert!(rate > 0.01, "Detectors should fire with p=0.05, got {rate}"); + assert!( + rate < 0.99, + "Detectors should not fire every shot, got {rate}" + ); +} diff --git a/crates/pecos-qis/src/lib.rs b/crates/pecos-qis/src/lib.rs index cfe7246f0..44478f886 100644 --- a/crates/pecos-qis/src/lib.rs +++ b/crates/pecos-qis/src/lib.rs @@ -102,7 +102,6 @@ pub use engine_builder::{QisEngineBuilder, qis_engine}; pub use program::{ InterfaceChoice, IntoQisInterface, ProgramType, QisEngineProgram, QisInterfaceBuilder, - QisInterfaceProvider, }; // ============================================================================ @@ -223,36 +222,3 @@ pub fn selene_soft_rz_engine() -> Result { .runtime(selene_soft_rz_runtime()?) .interface(helios_interface_builder())) } -/// Setup a QIS control engine for a program file (deprecated) -/// -/// **Deprecated**: This function is deprecated because it relied on implicit runtime selection. -/// Use `setup_qis_engine_with_runtime` instead and provide an explicit runtime. -/// -/// # Parameters -/// -/// - `program_path`: Path to the QIS program file (.ll or .bc) -/// -/// # Returns -/// -/// Returns an error directing users to use the explicit runtime version. -/// -/// # Errors -/// Always returns an error directing users to use `setup_qis_engine_with_runtime` instead. -#[deprecated( - since = "0.1.1", - note = "Use setup_qis_engine_with_runtime with an explicit runtime instead" -)] -pub fn setup_qis_engine( - _program_path: &Path, -) -> Result, PecosError> { - Err(PecosError::Processing( - "setup_qis_engine is deprecated.\n\ - \n\ - Please use setup_qis_engine_with_runtime and provide an explicit runtime:\n\ - \n\ - use pecos_qis::{setup_qis_engine_with_runtime, selene_simple_runtime};\n\ - \n\ - let engine = setup_qis_engine_with_runtime(path, selene_simple_runtime()?)?;" - .to_string(), - )) -} diff --git a/crates/pecos-qis/src/prelude.rs b/crates/pecos-qis/src/prelude.rs index 3d3b97056..05adeebe0 100644 --- a/crates/pecos-qis/src/prelude.rs +++ b/crates/pecos-qis/src/prelude.rs @@ -19,7 +19,6 @@ pub use crate::engine_builder::{QisEngineBuilder, qis_engine}; // Program types pub use crate::program::{ InterfaceChoice, IntoQisInterface, ProgramType, QisEngineProgram, QisInterfaceBuilder, - QisInterfaceProvider, }; // Convenience functions diff --git a/crates/pecos-qis/src/program.rs b/crates/pecos-qis/src/program.rs index d01775950..1ef2a6ec9 100644 --- a/crates/pecos-qis/src/program.rs +++ b/crates/pecos-qis/src/program.rs @@ -10,8 +10,6 @@ use pecos_core::errors::PecosError; use pecos_programs::{Hugr, Qis}; use pecos_qis_ffi_types::OperationCollector; -use std::process::Command; -use tempfile::NamedTempFile; /// A trait for types that can be converted into a `QisInterface` /// @@ -39,563 +37,6 @@ pub enum ProgramType { HugrBytes, } -/// Trait for different `QisInterface` implementation strategies -/// -/// This allows pluggable compilation strategies - Selene Helios compilation -/// or other future approaches. -pub trait QisInterfaceProvider: Send + Sync { - /// Get the interface (may involve compilation/linking) - /// - /// # Errors - /// Returns an error if the interface cannot be obtained (e.g., compilation/linking failures). - fn get_interface(&mut self) -> Result; - - /// Get provider type for debugging and logging - fn provider_type(&self) -> &'static str; - - /// Check if this provider can handle the given program type - fn can_handle(&self, program_type: &ProgramType) -> bool; - - /// Get any metadata about the compilation process - fn get_metadata(&self) -> std::collections::BTreeMap { - std::collections::BTreeMap::new() - } -} - -/// Selene Helios-based `QisInterface` provider -/// -/// This provider uses Selene's Helios compiler to compile QIS bitcode -/// into optimized quantum programs, then converts the result into a `QisInterface`. -#[derive(Debug)] -pub struct QisSeleneHeliosInterface { - program_data: Vec, - program_type: ProgramType, - metadata: std::collections::BTreeMap, - helios_config: HeliosConfig, -} - -/// Configuration for Selene Helios compilation -#[derive(Debug, Clone)] -pub struct HeliosConfig { - /// Optimization level (0-3) - pub opt_level: u8, - /// Target triple for compilation - pub target_triple: String, - /// Additional compilation flags - pub extra_flags: Vec, - /// Path to Selene installation - pub selene_path: Option, -} - -impl Default for HeliosConfig { - fn default() -> Self { - Self { - opt_level: 2, - target_triple: "native".to_string(), - extra_flags: Vec::new(), - selene_path: None, - } - } -} - -impl QisSeleneHeliosInterface { - /// Create a new Selene Helios interface provider from QIS bitcode - #[must_use] - pub fn from_bitcode(bitcode: Vec) -> Self { - Self::from_bitcode_with_config(bitcode, HeliosConfig::default()) - } - - /// Create a new Selene Helios interface provider with custom configuration - #[must_use] - pub fn from_bitcode_with_config(bitcode: Vec, config: HeliosConfig) -> Self { - let mut metadata = std::collections::BTreeMap::new(); - metadata.insert("bitcode_size".to_string(), bitcode.len().to_string()); - metadata.insert( - "compilation_strategy".to_string(), - "selene_helios".to_string(), - ); - metadata.insert("opt_level".to_string(), config.opt_level.to_string()); - - Self { - program_data: bitcode, - program_type: ProgramType::QisBitcode, - metadata, - helios_config: config, - } - } - - /// Create a new Selene Helios interface provider from HUGR bytes - #[must_use] - pub fn from_hugr_bytes(hugr_bytes: Vec) -> Self { - Self::from_hugr_bytes_with_config(hugr_bytes, HeliosConfig::default()) - } - - /// Create a new Selene Helios interface provider from HUGR bytes with custom configuration - #[must_use] - pub fn from_hugr_bytes_with_config(hugr_bytes: Vec, config: HeliosConfig) -> Self { - let mut metadata = std::collections::BTreeMap::new(); - metadata.insert("hugr_size".to_string(), hugr_bytes.len().to_string()); - metadata.insert( - "compilation_strategy".to_string(), - "selene_helios".to_string(), - ); - metadata.insert("opt_level".to_string(), config.opt_level.to_string()); - - Self { - program_data: hugr_bytes, - program_type: ProgramType::HugrBytes, - metadata, - helios_config: config, - } - } - - /// Create from LLVM IR text by converting to bitcode - #[must_use] - pub fn from_llvm_ir(llvm_ir: &str) -> Self { - #[cfg(feature = "llvm")] - { - // Convert LLVM IR text to bitcode using inkwell - use inkwell::context::Context; - use inkwell::targets::{InitializationConfig, Target}; - - // Initialize LLVM targets - Target::initialize_native(&InitializationConfig::default()).ok(); - - let context = Context::create(); - let bitcode = match context.create_module_from_ir( - inkwell::memory_buffer::MemoryBuffer::create_from_memory_range( - llvm_ir.as_bytes(), - "llvm_ir", - ), - ) { - Ok(module) => { - // Write module to bitcode - module.write_bitcode_to_memory().as_slice().to_vec() - } - Err(e) => { - log::error!("Failed to convert LLVM IR to bitcode: {e}"); - // Store the IR text as-is and let Helios handle it - llvm_ir.as_bytes().to_vec() - } - }; - - let mut interface = Self::from_bitcode(bitcode); - interface - .metadata - .insert("original_format".to_string(), "llvm_ir".to_string()); - interface - .metadata - .insert("ir_size".to_string(), llvm_ir.len().to_string()); - interface - } - - #[cfg(not(feature = "llvm"))] - { - // Without LLVM support, store the IR text as-is and let Helios handle it - let mut interface = Self::from_bitcode(llvm_ir.as_bytes().to_vec()); - interface - .metadata - .insert("original_format".to_string(), "llvm_ir".to_string()); - interface - .metadata - .insert("ir_size".to_string(), llvm_ir.len().to_string()); - interface - .metadata - .insert("llvm_conversion".to_string(), "skipped".to_string()); - interface - } - } - - /// Compile the program using Selene Helios and convert to `QisInterface` - fn compile_with_helios(&mut self) -> Result { - log::info!( - "Using Selene Helios compilation strategy for {:?}", - self.program_type - ); - - match self.program_type { - ProgramType::QisBitcode => { - self.compile_bitcode_with_helios() - } - ProgramType::HugrBytes => { - self.compile_hugr_with_helios() - } - ProgramType::LlvmIr => { - Err(PecosError::Generic( - "Selene Helios interface cannot compile LLVM IR text directly.\n\ - \n\ - The Helios interface is designed for HUGR bytes and QIS bitcode formats.\n\ - For LLVM IR text, please convert to bitcode first or use a different interface.\n\ - \n\ - This is a deprecated code path - modern PECOS uses Selene for all QIS programs.".to_string() - )) - } - } - } - - /// Compile QIS bitcode using Selene Helios - fn compile_bitcode_with_helios(&mut self) -> Result { - // Compile bitcode to LLVM IR using Selene Helios - let _llvm_ir = self.compile_bitcode_to_llvm_ir()?; - - // This old implementation is deprecated - use QisHeliosInterface instead - Err(PecosError::Processing( - "QisSeleneHeliosInterface is deprecated. Use pecos_qis::QisHeliosInterface instead." - .to_string(), - )) - } - - /// Compile HUGR bytes using Selene Helios - fn compile_hugr_with_helios(&mut self) -> Result { - // Use Selene HUGR compiler (no fallback) - let _llvm_ir = compile_hugr_with_selene(&self.program_data)?; - - // This old implementation is deprecated - use QisHeliosInterface instead - Err(PecosError::Processing( - "QisSeleneHeliosInterface is deprecated. Use pecos_qis::QisHeliosInterface instead." - .to_string(), - )) - } - - /// Compile QIS bitcode to LLVM IR using Selene Helios compiler - fn compile_bitcode_to_llvm_ir(&mut self) -> Result { - use std::io::Write; - use tempfile::NamedTempFile; - - // Write bitcode to a temporary file - let mut bitcode_file = NamedTempFile::new() - .map_err(|e| PecosError::Generic(format!("Failed to create temp file: {e}")))?; - bitcode_file - .write_all(&self.program_data) - .map_err(|e| PecosError::Generic(format!("Failed to write bitcode: {e}")))?; - - // Try multiple strategies to find and use Selene Helios - self.try_selene_helios_compilation(&bitcode_file) - } - - /// Try different strategies for Selene Helios compilation - fn try_selene_helios_compilation( - &mut self, - bitcode_file: &NamedTempFile, - ) -> Result { - let strategy_names = [ - "Custom Path", - "Environment Variable", - "Standard Locations", - "Conda Environment", - "System Installation", - ]; - - let strategies = [ - self.try_custom_selene_path(bitcode_file), - self.try_env_selene_path(bitcode_file), - self.try_standard_selene_locations(bitcode_file), - self.try_conda_selene(bitcode_file), - self.try_system_selene(bitcode_file), - ]; - - for (strategy_name, result) in strategy_names.iter().zip(strategies.iter()) { - match result { - Ok(llvm_ir) => { - log::info!("Selene Helios compilation succeeded using: {strategy_name}"); - self.metadata - .insert("helios_strategy".to_string(), (*strategy_name).to_string()); - self.metadata - .insert("helios_compilation".to_string(), "success".to_string()); - self.metadata - .insert("llvm_ir_size".to_string(), llvm_ir.len().to_string()); - return Ok(llvm_ir.clone()); - } - Err(e) => { - log::debug!("Selene Helios strategy '{strategy_name}' failed: {e}"); - self.metadata.insert( - format!( - "helios_strategy_{}_error", - strategy_name.to_lowercase().replace(' ', "_") - ), - e.to_string(), - ); - } - } - } - - // If all strategies fail, provide helpful error message - Err(PecosError::Generic(format!( - "Selene Helios compilation failed. Unable to find Selene installation after trying: {}. \n\ - \n\ - To use Selene Helios interface, you need to:\n\ - 1. Install Selene (https://github.com/Quantinuum/selene)\n\ - 2. Set SELENE_PATH environment variable to the Selene directory\n\ - \n\ - Selene is the only supported interface for QIS programs in modern PECOS.", - strategy_names.join(", ") - ))) - } - - /// Try compilation using user-provided Selene path - fn try_custom_selene_path(&self, bitcode_file: &NamedTempFile) -> Result { - let selene_path = self - .helios_config - .selene_path - .as_ref() - .ok_or_else(|| PecosError::Generic("No custom Selene path provided".to_string()))?; - - self.run_selene_helios_compiler(selene_path, bitcode_file) - } - - /// Try compilation using `SELENE_PATH` environment variable - fn try_env_selene_path(&self, bitcode_file: &NamedTempFile) -> Result { - let selene_path = std::env::var("SELENE_PATH") - .map_err(|_| PecosError::Generic("SELENE_PATH not set".to_string()))?; - - let path = std::path::PathBuf::from(selene_path); - self.run_selene_helios_compiler(&path, bitcode_file) - } - - /// Try compilation using standard Selene installation locations - fn try_standard_selene_locations( - &self, - bitcode_file: &NamedTempFile, - ) -> Result { - let standard_paths = [ - "/home/ciaranra/Repos/cl_projects/gup/selene", - "/opt/selene", - "/usr/local/selene", - "~/selene", - "./selene", - "../selene", - ]; - - for path_str in &standard_paths { - let path = std::path::PathBuf::from(path_str); - if path.exists() && path.join("selene-compilers/helios/python").exists() { - log::debug!("Found Selene at standard location: {}", path.display()); - return self.run_selene_helios_compiler(&path, bitcode_file); - } - } - - Err(PecosError::Generic( - "No Selene found in standard locations".to_string(), - )) - } - - /// Try compilation using conda environment - fn try_conda_selene(&self, bitcode_file: &NamedTempFile) -> Result { - // Check if we're in a conda environment with Selene - let python_script = r" -import sys -try: - import selene_helios_compiler - print(selene_helios_compiler.__file__) -except ImportError: - sys.exit(1) -" - .to_string(); - - let output = Command::new("python3") - .arg("-c") - .arg(&python_script) - .output() - .map_err(|e| PecosError::Generic(format!("Failed to check conda Selene: {e}")))?; - - if !output.status.success() { - return Err(PecosError::Generic( - "Selene not available in conda environment".to_string(), - )); - } - - // Run compilation directly using available Python module - self.run_conda_selene_compilation(bitcode_file) - } - - /// Try compilation using system-installed Selene - fn try_system_selene(&self, bitcode_file: &NamedTempFile) -> Result { - // Check if selene-helios command is available in PATH - let output = Command::new("which") - .arg("selene-helios") - .output() - .map_err(|_| PecosError::Generic("selene-helios not in PATH".to_string()))?; - - if !output.status.success() { - return Err(PecosError::Generic( - "selene-helios command not found".to_string(), - )); - } - - // Use command-line tool - self.run_system_selene_compilation(bitcode_file) - } - - /// Run Selene Helios compiler from a specific path - fn run_selene_helios_compiler( - &self, - selene_path: &std::path::Path, - bitcode_file: &NamedTempFile, - ) -> Result { - let helios_python_path = selene_path.join("selene-compilers/helios/python"); - - if !helios_python_path.exists() { - return Err(PecosError::Generic(format!( - "Selene Helios Python path not found: {}", - helios_python_path.display() - ))); - } - - let python_script = format!( - r#" -import sys -sys.path.insert(0, '{helios_python_path}') - -try: - from selene_helios_compiler import compile_bitcode_to_llvm_ir -except ImportError as e: - print(f"Failed to import Selene Helios compiler: {{e}}", file=sys.stderr) - sys.exit(1) - -try: - with open('{bitcode_path}', 'rb') as f: - bitcode = f.read() - - llvm_ir = compile_bitcode_to_llvm_ir( - bitcode, - opt_level={opt_level}, - target_triple='{target_triple}' - ) - print(llvm_ir) -except Exception as e: - print(f"Compilation failed: {{e}}", file=sys.stderr) - sys.exit(1) -"#, - helios_python_path = helios_python_path.display(), - bitcode_path = bitcode_file.path().display(), - opt_level = self.helios_config.opt_level, - target_triple = self.helios_config.target_triple - ); - - let output = Command::new("python3") - .arg("-c") - .arg(&python_script) - .output() - .map_err(|e| PecosError::Generic(format!("Failed to run Selene Helios: {e}")))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(PecosError::Generic(format!( - "Selene Helios compilation failed: {stderr}" - ))); - } - - let llvm_ir = String::from_utf8(output.stdout) - .map_err(|e| PecosError::Generic(format!("Invalid UTF-8 output: {e}")))?; - - log::debug!( - "Successfully compiled bitcode using Selene Helios from: {}", - selene_path.display() - ); - Ok(llvm_ir.trim().to_string()) - } - - /// Run Selene Helios compilation using conda environment - fn run_conda_selene_compilation( - &self, - bitcode_file: &NamedTempFile, - ) -> Result { - let python_script = format!( - r#" -import selene_helios_compiler - -try: - with open('{bitcode_path}', 'rb') as f: - bitcode = f.read() - - llvm_ir = selene_helios_compiler.compile_bitcode_to_llvm_ir( - bitcode, - opt_level={opt_level}, - target_triple='{target_triple}' - ) - print(llvm_ir) -except Exception as e: - import sys - print(f"Conda Selene compilation failed: {{e}}", file=sys.stderr) - sys.exit(1) -"#, - bitcode_path = bitcode_file.path().display(), - opt_level = self.helios_config.opt_level, - target_triple = self.helios_config.target_triple - ); - - let output = Command::new("python3") - .arg("-c") - .arg(&python_script) - .output() - .map_err(|e| PecosError::Generic(format!("Failed to run conda Selene: {e}")))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(PecosError::Generic(format!( - "Conda Selene compilation failed: {stderr}" - ))); - } - - let llvm_ir = String::from_utf8(output.stdout) - .map_err(|e| PecosError::Generic(format!("Invalid UTF-8 output: {e}")))?; - - Ok(llvm_ir.trim().to_string()) - } - - /// Run Selene Helios compilation using system command - fn run_system_selene_compilation( - &self, - bitcode_file: &NamedTempFile, - ) -> Result { - let output = Command::new("selene-helios") - .arg("compile") - .arg("--input") - .arg(bitcode_file.path()) - .arg("--output-format") - .arg("llvm-ir") - .arg("--opt-level") - .arg(self.helios_config.opt_level.to_string()) - .arg("--target-triple") - .arg(&self.helios_config.target_triple) - .output() - .map_err(|e| PecosError::Generic(format!("Failed to run system Selene: {e}")))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(PecosError::Generic(format!( - "System Selene compilation failed: {stderr}" - ))); - } - - let llvm_ir = String::from_utf8(output.stdout) - .map_err(|e| PecosError::Generic(format!("Invalid UTF-8 output: {e}")))?; - - Ok(llvm_ir.trim().to_string()) - } -} - -impl QisInterfaceProvider for QisSeleneHeliosInterface { - fn get_interface(&mut self) -> Result { - self.compile_with_helios() - } - - fn provider_type(&self) -> &'static str { - "Selene Helios" - } - - fn can_handle(&self, program_type: &ProgramType) -> bool { - matches!( - program_type, - ProgramType::QisBitcode | ProgramType::HugrBytes - ) - } - - fn get_metadata(&self) -> std::collections::BTreeMap { - self.metadata.clone() - } -} - /// Implement `IntoQisInterface` for `OperationCollector` itself (identity conversion) impl IntoQisInterface for OperationCollector { fn into_qis_interface(self) -> Result { @@ -811,70 +252,3 @@ impl From for QisEngineProgram { // Tests for program conversion require actual interface implementations // and are in the integration test files. - -/// Compile HUGR bytes using Selene's compiler -/// -/// This uses Selene's proven HUGR→LLVM compiler, ensuring proper qubit ID -/// management and QIS function generation. Returns explicit error if Selene is not available. -fn compile_hugr_with_selene(hugr_bytes: &[u8]) -> Result { - log::info!("Compiling HUGR with Selene compiler (required)"); - - // Use Selene's Python compiler - no fallbacks - compile_hugr_with_selene_python(hugr_bytes).map_err(|e| { - PecosError::Generic(format!( - "Selene Helios compilation failed: {e}\n\n\ - To use Helios interface, ensure Selene is installed and available:\n\ - 1. Ensure Selene repository is at ../selene or ../../../selene\n\ - 2. Build Selene compilers: 'cargo build --release' in Selene directory\n\ - \n\ - Selene is the only supported interface for QIS programs." - )) - }) -} - -/// Compile HUGR using Selene's Python compiler -fn compile_hugr_with_selene_python(hugr_bytes: &[u8]) -> Result { - use std::io::Write; - use tempfile::NamedTempFile; - - // Write HUGR bytes to a temporary file - let mut hugr_file = NamedTempFile::new() - .map_err(|e| PecosError::Generic(format!("Failed to create temp file: {e}")))?; - hugr_file - .write_all(hugr_bytes) - .map_err(|e| PecosError::Generic(format!("Failed to write HUGR bytes: {e}")))?; - - // Call Selene's compiler using Python - let output = Command::new("python3") - .arg("-c") - .arg(format!( - r" -import sys -sys.path.insert(0, '{}/selene-compilers/hugr_qis/python') -from selene_hugr_qis_compiler import compile_to_llvm_ir - -with open('{}', 'rb') as f: - hugr_bytes = f.read() - -llvm_ir = compile_to_llvm_ir(hugr_bytes, opt_level=2, target_triple='native') -print(llvm_ir) -", - "/home/ciaranra/Repos/cl_projects/gup/selene", - hugr_file.path().display() - )) - .output() - .map_err(|e| PecosError::Generic(format!("Failed to run Selene compiler: {e}")))?; - - if !output.status.success() { - let stderr = String::from_utf8_lossy(&output.stderr); - return Err(PecosError::Generic(format!( - "Selene compiler failed: {stderr}" - ))); - } - - let llvm_ir = String::from_utf8(output.stdout) - .map_err(|e| PecosError::Generic(format!("Invalid UTF-8 output: {e}")))?; - - log::debug!("Successfully compiled HUGR using Selene compiler"); - Ok(llvm_ir) -} diff --git a/crates/pecos-quantum/Cargo.toml b/crates/pecos-quantum/Cargo.toml index e34b23dc4..cd17ac25f 100644 --- a/crates/pecos-quantum/Cargo.toml +++ b/crates/pecos-quantum/Cargo.toml @@ -14,8 +14,10 @@ readme = "README.md" [dependencies] pecos-core.workspace = true pecos-num.workspace = true +pecos-random.workspace = true nalgebra.workspace = true num-complex.workspace = true +serde_json.workspace = true smallvec.workspace = true tket = { workspace = true, optional = true } log.workspace = true diff --git a/crates/pecos-quantum/examples/style_demo.rs b/crates/pecos-quantum/examples/style_demo.rs index c3e1b79f7..64703f4cf 100644 --- a/crates/pecos-quantum/examples/style_demo.rs +++ b/crates/pecos-quantum/examples/style_demo.rs @@ -482,7 +482,7 @@ fn main() { // -- UnitaryRep example -- html.push_str("

UnitaryRep Algebra

\n"); { - use pecos_core::unitary_rep::{CX, H, T}; + use pecos_core::unitary::{CX, H, T}; let circuit = T(1) * CX(0, 1) * H(0); let op_renderer = circuit.render_with(2, &default_style); write!( diff --git a/crates/pecos-quantum/src/channel.rs b/crates/pecos-quantum/src/channel.rs new file mode 100644 index 000000000..430256286 --- /dev/null +++ b/crates/pecos-quantum/src/channel.rs @@ -0,0 +1,4585 @@ +// 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. + +//! Sparse Pauli-basis channel and operator representations. +//! +//! This module keeps three related representations separate: +//! - [`PauliSum`] stores arbitrary complex coefficients on Pauli operators. +//! - [`PauliChannel`] stores real Pauli error probabilities. +//! - [`DiagonalPtm`] stores real diagonal Pauli-transfer-matrix entries, also +//! called Pauli fidelities for Pauli channels. +//! - [`Ptm`] stores a dense real Pauli-transfer matrix. +//! - [`KrausOps`] stores a concrete Kraus-operator channel representation. +//! - [`ChoiMatrix`] stores a concrete Choi representation. +//! - [`SuperOp`] stores a dense column-stacked superoperator. +//! - [`ChiMatrix`] stores a process matrix in the Pauli basis. +//! - [`Stinespring`] stores a Stinespring isometry. +//! +//! Pauli-channel probabilities and diagonal PTM entries are connected by an +//! explicit Walsh-Hadamard transform. They are not the same representation: +//! probabilities are non-negative and sum to one, while diagonal PTM entries +//! may be negative. + +use std::collections::{BTreeMap, BTreeSet}; +use std::error::Error; +use std::f64::consts::TAU; +use std::fmt; +use std::ops::{Add, Mul}; + +use nalgebra::{DMatrix, SVD}; +use num_complex::Complex64; +use pecos_core::{ + BitmaskStorage, ChannelExpr, Clifford, Pauli, PauliBitmaskSmall, PauliString, Phase as _, + UnitaryRep, +}; +use pecos_random::{Rng, RngExt as _}; + +use crate::unitary_matrix::to_matrix_with_size; + +const DEFAULT_TOLERANCE: f64 = 1e-12; + +/// Pauli basis ordering used by channel representations. +/// +/// The current PECOS channel basis uses lexicographic Pauli digits with +/// `I=0`, `X=1`, `Y=2`, `Z=3`, and qubit 0 as the least-significant base-4 +/// digit. For two qubits, labels displayed from high qubit to low qubit are: +/// `II, IX, IY, IZ, XI, XX, XY, XZ, ...`. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub enum PtmBasisOrder { + /// Lexicographic Pauli basis with qubit 0 as the fastest-varying digit. + #[default] + LexicographicLittleEndian, +} + +/// Error returned by channel representation constructors and conversions. +#[derive(Debug, Clone, PartialEq)] +pub enum ChannelError { + /// The requested number of qubits would overflow a `usize` dimension. + DimensionOverflow { + /// Number of qubits supplied by the caller. + num_qubits: usize, + }, + /// A value was not a valid Pauli basis digit. + InvalidBasisDigit { + /// Invalid digit. + digit: usize, + }, + /// A Pauli-basis index is outside the basis for the requested qubit count. + BasisIndexOutOfRange { + /// Number of qubits supplied by the caller. + num_qubits: usize, + /// Basis length for that qubit count. + basis_len: usize, + /// Invalid index. + index: usize, + }, + /// A Pauli term acts outside the declared qubit range. + QubitOutOfRange { + /// Number of qubits supplied by the caller. + num_qubits: usize, + /// Highest qubit touched by the offending Pauli term. + qubit: usize, + }, + /// Two channel objects act on different numbers of qubits. + QubitCountMismatch { + /// Expected qubit count. + expected: usize, + /// Actual qubit count. + actual: usize, + }, + /// A coefficient or fidelity is not finite. + InvalidCoefficient { + /// Offending coefficient. + value: Complex64, + }, + /// A `PauliSum` coefficient was not real enough for a probability. + NonRealCoefficient { + /// Offending coefficient. + value: Complex64, + /// Allowed imaginary-part tolerance. + tolerance: f64, + }, + /// A probability value is invalid for a Pauli channel. + InvalidProbability { + /// Probability value. + value: f64, + /// Allowed negative tolerance. + tolerance: f64, + }, + /// A probability map does not sum to one within tolerance. + ProbabilitySum { + /// Observed probability sum. + sum: f64, + /// Allowed absolute tolerance. + tolerance: f64, + }, + /// A dense matrix does not match the expected channel or density-matrix + /// shape. + InvalidMatrixShape { + /// Expected row count. + expected_rows: usize, + /// Expected column count. + expected_cols: usize, + /// Actual row count. + rows: usize, + /// Actual column count. + cols: usize, + }, + /// A Kraus channel was constructed without any Kraus operators. + EmptyKrausSet, + /// A numerical matrix decomposition failed. + DecompositionFailed { + /// Human-readable reason. + reason: String, + }, + /// A channel expression is outside the supported conversion subset. + UnsupportedChannelExpr { + /// Human-readable reason. + reason: String, + }, + /// A repeated subsystem was supplied. + DuplicateSubsystem { + /// Repeated qubit/subsystem index. + qubit: usize, + }, + /// A tomography reconstruction had the wrong number of basis samples. + InvalidTomographySampleCount { + /// Expected number of operator-basis outputs. + expected: usize, + /// Actual number of supplied outputs. + actual: usize, + }, + /// A tomography input index is outside the experiment design. + TomographyInputOutOfRange { + /// Number of tomography inputs in the design. + num_inputs: usize, + /// Invalid input index. + index: usize, + }, + /// A computational matrix-unit row/column is outside the Hilbert space. + MatrixUnitOutOfRange { + /// Hilbert-space dimension. + dim: usize, + /// Invalid row. + row: usize, + /// Invalid column. + col: usize, + }, +} + +impl fmt::Display for ChannelError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DimensionOverflow { num_qubits } => write!( + f, + "Pauli-basis dimension overflows usize for {num_qubits} qubits" + ), + Self::InvalidBasisDigit { digit } => write!(f, "invalid Pauli basis digit: {digit}"), + Self::BasisIndexOutOfRange { + num_qubits, + basis_len, + index, + } => write!( + f, + "Pauli-basis index {index} is outside the {basis_len}-element basis for {num_qubits} qubits" + ), + Self::QubitOutOfRange { num_qubits, qubit } => write!( + f, + "Pauli term touches qubit {qubit}, outside declared {num_qubits}-qubit range" + ), + Self::QubitCountMismatch { expected, actual } => write!( + f, + "channel qubit count mismatch: expected {expected}, got {actual}" + ), + Self::InvalidCoefficient { value } => { + write!(f, "invalid non-finite coefficient: {value}") + } + Self::NonRealCoefficient { value, tolerance } => write!( + f, + "coefficient {value} is not real within tolerance {tolerance}" + ), + Self::InvalidProbability { value, tolerance } => write!( + f, + "invalid Pauli-channel probability {value}; tolerance is {tolerance}" + ), + Self::ProbabilitySum { sum, tolerance } => write!( + f, + "Pauli-channel probabilities must sum to 1 within tolerance {tolerance}, got {sum}" + ), + Self::InvalidMatrixShape { + expected_rows, + expected_cols, + rows, + cols, + } => write!( + f, + "invalid matrix shape {rows}x{cols}; expected {expected_rows}x{expected_cols}" + ), + Self::EmptyKrausSet => write!(f, "Kraus channel must contain at least one operator"), + Self::DecompositionFailed { reason } => { + write!(f, "matrix decomposition failed: {reason}") + } + Self::UnsupportedChannelExpr { reason } => { + write!(f, "unsupported channel expression: {reason}") + } + Self::DuplicateSubsystem { qubit } => { + write!(f, "duplicate subsystem/qubit index: {qubit}") + } + Self::InvalidTomographySampleCount { expected, actual } => write!( + f, + "invalid tomography sample count {actual}; expected {expected} operator-basis outputs" + ), + Self::TomographyInputOutOfRange { num_inputs, index } => write!( + f, + "tomography input index {index} is outside the {num_inputs}-input design" + ), + Self::MatrixUnitOutOfRange { dim, row, col } => write!( + f, + "matrix unit |{row}><{col}| is outside the {dim}-dimensional Hilbert space" + ), + } + } +} + +impl Error for ChannelError {} + +/// Returns the number of Pauli basis elements for `num_qubits`. +/// +/// This is `4^num_qubits`. +/// +/// # Errors +/// +/// Returns [`ChannelError::DimensionOverflow`] when `4^num_qubits` does not +/// fit in `usize`. +pub fn pauli_basis_len(num_qubits: usize) -> Result { + 4usize + .checked_pow( + num_qubits + .try_into() + .map_err(|_| ChannelError::DimensionOverflow { num_qubits })?, + ) + .ok_or(ChannelError::DimensionOverflow { num_qubits }) +} + +/// Maps a Pauli value to the channel-basis digit `I=0, X=1, Y=2, Z=3`. +/// +/// This intentionally differs from the internal [`Pauli`] discriminant order, +/// where `Z` and `Y` are stored in bitmask-friendly order. +#[must_use] +pub fn pauli_to_basis_digit(pauli: Pauli) -> usize { + match pauli { + Pauli::I => 0, + Pauli::X => 1, + Pauli::Y => 2, + Pauli::Z => 3, + } +} + +/// Converts a channel-basis digit to a Pauli value. +/// +/// # Errors +/// +/// Returns [`ChannelError::InvalidBasisDigit`] when `digit` is not in `0..4`. +pub fn basis_digit_to_pauli(digit: usize) -> Result { + match digit { + 0 => Ok(Pauli::I), + 1 => Ok(Pauli::X), + 2 => Ok(Pauli::Y), + 3 => Ok(Pauli::Z), + _ => Err(ChannelError::InvalidBasisDigit { digit }), + } +} + +/// Returns the Pauli basis element at `index`. +/// +/// The returned vector is indexed by qubit number: element 0 is the Pauli on +/// qubit 0. Qubit 0 is the fastest-varying base-4 digit. +/// +/// # Errors +/// +/// Returns an error when `4^num_qubits` overflows or `index` is outside the +/// basis. +pub fn basis_element(num_qubits: usize, index: usize) -> Result, ChannelError> { + let basis_len = pauli_basis_len(num_qubits)?; + if index >= basis_len { + return Err(ChannelError::BasisIndexOutOfRange { + num_qubits, + basis_len, + index, + }); + } + + let mut remaining = index; + let mut paulis = Vec::with_capacity(num_qubits); + for _ in 0..num_qubits { + paulis.push(basis_digit_to_pauli(remaining & 0b11)?); + remaining >>= 2; + } + Ok(paulis) +} + +/// Returns a display label for a Pauli basis element. +/// +/// Labels are printed with the highest-numbered qubit first, matching common +/// ket-label display style. For example, basis index 1 on two qubits is `IX` +/// because it is identity on qubit 1 and X on qubit 0. +/// +/// # Errors +/// +/// Returns an error when `4^num_qubits` overflows or `index` is outside the +/// basis. +pub fn basis_label(num_qubits: usize, index: usize) -> Result { + let paulis = basis_element(num_qubits, index)?; + Ok(paulis + .iter() + .rev() + .map(|pauli| match pauli { + Pauli::I => 'I', + Pauli::X => 'X', + Pauli::Y => 'Y', + Pauli::Z => 'Z', + }) + .collect()) +} + +/// Returns the Pauli bitmask basis element at `index`. +/// +/// # Errors +/// +/// Returns an error when `4^num_qubits` overflows or `index` is outside the +/// basis. +pub fn basis_bitmask(num_qubits: usize, index: usize) -> Result { + let paulis = basis_element(num_qubits, index)?; + Ok(bitmask_from_paulis(&paulis)) +} + +/// Returns the Pauli-basis index for a bitmask in the canonical ordering. +/// +/// # Errors +/// +/// Returns [`ChannelError::QubitOutOfRange`] when the bitmask touches a qubit +/// outside `0..num_qubits`. +pub fn basis_index(num_qubits: usize, pauli: &PauliBitmaskSmall) -> Result { + pauli_basis_len(num_qubits)?; + validate_num_qubits(num_qubits, pauli)?; + let mut index = 0usize; + for qubit in 0..num_qubits { + let digit = match (pauli.has_x(qubit), pauli.has_z(qubit)) { + (false, false) => 0, + (true, false) => 1, + (true, true) => 2, + (false, true) => 3, + }; + index += digit << (2 * qubit); + } + Ok(index) +} + +/// Returns the canonical display label for a bitmask. +/// +/// # Errors +/// +/// Returns [`ChannelError::QubitOutOfRange`] when the bitmask touches a qubit +/// outside `0..num_qubits`. +pub fn bitmask_label(num_qubits: usize, pauli: &PauliBitmaskSmall) -> Result { + basis_label(num_qubits, basis_index(num_qubits, pauli)?) +} + +/// Converts a [`PauliString`] into the phase-free bitmask used by channel +/// representations. +/// +/// # Errors +/// +/// Returns [`ChannelError::QubitOutOfRange`] when the Pauli string touches a +/// qubit outside `0..num_qubits`. +pub fn pauli_string_to_bitmask( + num_qubits: usize, + pauli: &PauliString, +) -> Result { + let mut out = PauliBitmaskSmall::identity(); + for (p, q) in pauli.iter_pairs() { + let q = q.index(); + if q >= num_qubits { + return Err(ChannelError::QubitOutOfRange { + num_qubits, + qubit: q, + }); + } + match p { + Pauli::I => {} + Pauli::X => out.x_bits.set_bit(q), + Pauli::Y => { + out.x_bits.set_bit(q); + out.z_bits.set_bit(q); + } + Pauli::Z => out.z_bits.set_bit(q), + } + } + Ok(out) +} + +/// Sparse complex sum of Pauli operators. +#[derive(Clone, Debug, PartialEq)] +pub struct PauliSum { + num_qubits: usize, + terms: BTreeMap, +} + +impl PauliSum { + /// Constructs an empty Pauli sum over `num_qubits`. + #[must_use] + pub fn new(num_qubits: usize) -> Self { + Self { + num_qubits, + terms: BTreeMap::new(), + } + } + + /// Constructs a Pauli sum after validating term qubit ranges and + /// simplifying near-zero coefficients. + /// + /// # Errors + /// + /// Returns an error when any term touches a qubit outside `0..num_qubits` + /// or any coefficient is not finite. + pub fn try_new( + num_qubits: usize, + terms: BTreeMap, + ) -> Result { + Self::try_new_with_tolerance(num_qubits, terms, DEFAULT_TOLERANCE) + } + + /// Constructs a Pauli sum with an explicit zero-dropping tolerance. + /// + /// # Errors + /// + /// Returns an error when any term touches a qubit outside `0..num_qubits` + /// or any coefficient is not finite. + pub fn try_new_with_tolerance( + num_qubits: usize, + terms: BTreeMap, + tolerance: f64, + ) -> Result { + let mut out = Self::new(num_qubits); + for (pauli, coefficient) in terms { + out.add_term_with_tolerance(pauli, coefficient, tolerance)?; + } + Ok(out) + } + + /// Constructs a Pauli sum containing one [`PauliString`]. + /// + /// The `PauliString` phase becomes the complex coefficient. The stored + /// Pauli label itself is phase-free. + /// + /// # Errors + /// + /// Returns an error when `pauli` touches a qubit outside `0..num_qubits`. + pub fn from_pauli_string(num_qubits: usize, pauli: &PauliString) -> Result { + let label = pauli_string_to_bitmask(num_qubits, pauli)?; + let coefficient = pauli.phase().to_complex(); + let mut terms = BTreeMap::new(); + terms.insert(label, coefficient); + Self::try_new(num_qubits, terms) + } + + /// Returns the number of qubits represented by this sum. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Returns the sparse Pauli terms and complex coefficients. + #[must_use] + pub fn terms(&self) -> &BTreeMap { + &self.terms + } + + /// Adds one term, merging with an existing coefficient if needed. + /// + /// # Errors + /// + /// Returns an error when `pauli` touches a qubit outside + /// `0..self.num_qubits` or `coefficient` is not finite. + pub fn add_term( + &mut self, + pauli: PauliBitmaskSmall, + coefficient: Complex64, + ) -> Result<(), ChannelError> { + self.add_term_with_tolerance(pauli, coefficient, DEFAULT_TOLERANCE) + } + + /// Adds one term with an explicit zero-dropping tolerance. + /// + /// # Errors + /// + /// Returns an error when `pauli` touches a qubit outside + /// `0..self.num_qubits` or `coefficient` is not finite. + pub fn add_term_with_tolerance( + &mut self, + pauli: PauliBitmaskSmall, + coefficient: Complex64, + tolerance: f64, + ) -> Result<(), ChannelError> { + validate_num_qubits(self.num_qubits, &pauli)?; + validate_complex(coefficient)?; + if coefficient.norm() <= tolerance { + return Ok(()); + } + + match self.terms.entry(pauli) { + std::collections::btree_map::Entry::Occupied(mut entry) => { + *entry.get_mut() += coefficient; + if entry.get().norm() <= tolerance { + entry.remove(); + } + } + std::collections::btree_map::Entry::Vacant(entry) => { + entry.insert(coefficient); + } + } + Ok(()) + } + + /// Drops near-zero coefficients in-place. + pub fn simplify_with_tolerance(&mut self, tolerance: f64) { + self.terms + .retain(|_, coefficient| coefficient.norm() > tolerance); + } + + /// Drops coefficients at the default tolerance and returns the simplified + /// sum. + #[must_use] + pub fn simplify(mut self) -> Self { + self.simplify_with_tolerance(DEFAULT_TOLERANCE); + self + } + + /// Greedily partitions terms into mutually commuting sums. + /// + /// Coefficients are preserved exactly. The grouping is a graph-coloring + /// heuristic on the anticommutation graph, so it is not guaranteed to use + /// the minimum possible number of groups. + #[must_use] + pub fn group_commuting(&self) -> Vec { + let mut groups: Vec> = Vec::new(); + + 'next_term: for (pauli, coefficient) in &self.terms { + for group in &mut groups { + if group.keys().all(|other| pauli.commutes_with(other)) { + group.insert(pauli.clone(), *coefficient); + continue 'next_term; + } + } + groups.push(BTreeMap::from([(pauli.clone(), *coefficient)])); + } + + groups + .into_iter() + .map(|terms| Self { + num_qubits: self.num_qubits, + terms, + }) + .collect() + } + + /// Returns the Pauli conjugation `P * self * P†`. + /// + /// Pauli conjugation preserves each Pauli label and flips the coefficient + /// sign for terms that anticommute with `P`. + /// + /// # Errors + /// + /// Returns an error when `pauli` touches a qubit outside this sum's qubit + /// range. + pub fn conjugated_by_pauli_string(&self, pauli: &PauliString) -> Result { + let label = pauli_string_to_bitmask(self.num_qubits, pauli)?; + let mut terms = BTreeMap::new(); + for (term, coefficient) in &self.terms { + let sign = if label.commutes_with(term) { 1.0 } else { -1.0 }; + terms.insert(term.clone(), *coefficient * sign); + } + Ok(Self { + num_qubits: self.num_qubits, + terms, + }) + } + + /// Adds two Pauli sums after validating that they act on the same number + /// of qubits. + /// + /// # Errors + /// + /// Returns [`ChannelError::QubitCountMismatch`] when the two sums have + /// different qubit counts. + pub fn try_add(mut self, rhs: Self) -> Result { + if self.num_qubits != rhs.num_qubits { + return Err(ChannelError::QubitCountMismatch { + expected: self.num_qubits, + actual: rhs.num_qubits, + }); + } + for (pauli, coefficient) in rhs.terms { + self.add_term(pauli, coefficient)?; + } + Ok(self) + } + + /// Returns the trace of the represented operator. + /// + /// The trace is `identity_coefficient * 2^num_qubits`. + /// + /// # Errors + /// + /// Returns [`ChannelError::DimensionOverflow`] when `2^num_qubits` cannot + /// fit in `usize`. + #[allow(clippy::cast_precision_loss)] + pub fn trace(&self) -> Result { + let dim = hilbert_dim(self.num_qubits)?; + Ok(self + .terms + .get(&PauliBitmaskSmall::identity()) + .copied() + .unwrap_or_else(|| Complex64::new(0.0, 0.0)) + * dim as f64) + } +} + +impl fmt::Display for PauliSum { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + if self.terms.is_empty() { + return write!(f, "0"); + } + for (idx, (pauli, coefficient)) in self.terms.iter().enumerate() { + if idx > 0 { + write!(f, " + ")?; + } + let label = bitmask_label(self.num_qubits, pauli).map_err(|_| fmt::Error)?; + write!(f, "({coefficient}){label}")?; + } + Ok(()) + } +} + +impl Add for PauliSum { + type Output = Self; + + /// Adds two Pauli sums. + /// + /// # Panics + /// + /// Panics when the sums have different qubit counts. Use + /// [`PauliSum::try_add`] to handle this case without panicking. + fn add(self, rhs: Self) -> Self::Output { + self.try_add(rhs) + .expect("cannot add PauliSum values with different qubit counts") + } +} + +impl Mul for PauliSum { + type Output = Self; + + fn mul(mut self, rhs: Complex64) -> Self::Output { + for coefficient in self.terms.values_mut() { + *coefficient *= rhs; + } + self.simplify() + } +} + +impl Mul for Complex64 { + type Output = PauliSum; + + fn mul(self, rhs: PauliSum) -> Self::Output { + rhs * self + } +} + +impl Mul for PauliSum { + type Output = Self; + + fn mul(self, rhs: f64) -> Self::Output { + self * Complex64::new(rhs, 0.0) + } +} + +impl Mul for f64 { + type Output = PauliSum; + + fn mul(self, rhs: PauliSum) -> Self::Output { + rhs * self + } +} + +/// Sparse Pauli error channel represented by probabilities. +#[derive(Clone, Debug, PartialEq)] +pub struct PauliChannel { + num_qubits: usize, + basis_order: PtmBasisOrder, + probabilities: BTreeMap, +} + +impl PauliChannel { + /// Constructs a Pauli channel after validating probabilities. + /// + /// Missing Pauli terms are treated as zero probability. Stored + /// probabilities must be finite, non-negative, and sum to one. + /// + /// # Errors + /// + /// Returns an error when a term is outside the declared qubit range, a + /// probability is non-finite or negative, or probabilities do not sum to + /// one. + pub fn try_new( + num_qubits: usize, + probabilities: BTreeMap, + ) -> Result { + Self::try_new_with_tolerance(num_qubits, probabilities, DEFAULT_TOLERANCE) + } + + /// Constructs a Pauli channel with an explicit tolerance. + /// + /// # Errors + /// + /// Returns an error when a term is outside the declared qubit range, a + /// probability is non-finite or negative, or probabilities do not sum to + /// one within `tolerance`. + pub fn try_new_with_tolerance( + num_qubits: usize, + probabilities: BTreeMap, + tolerance: f64, + ) -> Result { + let mut cleaned = BTreeMap::new(); + let mut sum = 0.0; + for (pauli, probability) in probabilities { + validate_num_qubits(num_qubits, &pauli)?; + validate_probability(probability, tolerance)?; + let probability = if probability.abs() <= tolerance { + 0.0 + } else { + probability + }; + if probability > 0.0 { + cleaned.insert(pauli, probability); + } + sum += probability; + } + if (sum - 1.0).abs() > tolerance { + return Err(ChannelError::ProbabilitySum { sum, tolerance }); + } + Ok(Self { + num_qubits, + basis_order: PtmBasisOrder::default(), + probabilities: cleaned, + }) + } + + /// Constructs a one-qubit Pauli channel from non-identity probabilities. + /// + /// # Errors + /// + /// Returns an error when probabilities are invalid or do not leave a valid + /// identity probability. + pub fn one_qubit(px: f64, py: f64, pz: f64) -> Result { + let mut probabilities = BTreeMap::new(); + probabilities.insert(PauliBitmaskSmall::identity(), 1.0 - px - py - pz); + probabilities.insert(PauliBitmaskSmall::x(0), px); + probabilities.insert(PauliBitmaskSmall::y(0), py); + probabilities.insert(PauliBitmaskSmall::z(0), pz); + Self::try_new(1, probabilities) + } + + /// Converts a real, non-negative [`PauliSum`] into a Pauli channel. + /// + /// # Errors + /// + /// Returns an error when any coefficient has a non-negligible imaginary + /// part, is negative, or the real coefficients do not sum to one. + pub fn from_pauli_sum(sum: &PauliSum) -> Result { + let mut probabilities = BTreeMap::new(); + for (pauli, coefficient) in sum.terms() { + if coefficient.im.abs() > DEFAULT_TOLERANCE { + return Err(ChannelError::NonRealCoefficient { + value: *coefficient, + tolerance: DEFAULT_TOLERANCE, + }); + } + probabilities.insert(pauli.clone(), coefficient.re); + } + Self::try_new(sum.num_qubits(), probabilities) + } + + /// Constructs a Pauli channel from probabilities keyed by [`PauliString`]. + /// + /// Pauli-string phases are ignored because Pauli channels apply + /// `P rho P†`, where global phase cancels. Repeated Pauli keys are + /// accumulated before validation. + /// + /// # Errors + /// + /// Returns an error when any Pauli string touches a qubit outside + /// `0..num_qubits`, a probability is invalid, or probabilities do not sum + /// to one. + pub fn from_pauli_strings(num_qubits: usize, probabilities: I) -> Result + where + I: IntoIterator, + { + let mut terms = BTreeMap::new(); + for (pauli, probability) in probabilities { + let pauli = pauli_string_to_bitmask(num_qubits, &pauli)?; + *terms.entry(pauli).or_insert(0.0) += probability; + } + Self::try_new(num_qubits, terms) + } + + /// Converts a symbolic channel expression into a Pauli channel when it is + /// a mixture of Pauli unitaries. + /// + /// This accepts the common constructors for bit-flip, dephasing, + /// depolarizing, and general mixed-Pauli channels. Non-Pauli unitary + /// mixtures are intentionally rejected instead of silently projecting them. + /// + /// # Errors + /// + /// Returns an error when `channel` is not a supported Pauli-unitary + /// mixture or when its probabilities are invalid. + pub fn from_channel_expr(channel: &ChannelExpr) -> Result { + let num_qubits = channel_num_qubits(channel).max(1); + match channel { + ChannelExpr::Unitary(unitary) => { + let pauli = unitary_rep_to_pauli_bitmask(num_qubits, unitary)?; + let mut probabilities = BTreeMap::new(); + probabilities.insert(pauli, 1.0); + Self::try_new(num_qubits, probabilities) + } + ChannelExpr::MixedUnitary(ops) => { + let mut probabilities = BTreeMap::new(); + for (probability, unitary) in ops { + validate_probability(*probability, DEFAULT_TOLERANCE)?; + let pauli = unitary_rep_to_pauli_bitmask(num_qubits, unitary)?; + *probabilities.entry(pauli).or_insert(0.0) += *probability; + } + Self::try_new(num_qubits, probabilities) + } + _ => Err(ChannelError::UnsupportedChannelExpr { + reason: "only Pauli-unitary channels can be converted to PauliChannel".to_string(), + }), + } + } + + /// Converts Pauli probabilities to diagonal PTM entries. + /// + /// # Errors + /// + /// Returns an error if the Pauli-basis dimension overflows. + pub fn to_diagonal_ptm(&self) -> Result { + let basis_len = pauli_basis_len(self.num_qubits)?; + let mut fidelities = BTreeMap::new(); + for basis_index in 0..basis_len { + let basis = basis_bitmask(self.num_qubits, basis_index)?; + let fidelity = self + .probabilities + .iter() + .map(|(error, probability)| probability * commutation_character(error, &basis)) + .sum(); + fidelities.insert(basis, fidelity); + } + DiagonalPtm::try_new(self.num_qubits, fidelities) + } + + /// Converts this Pauli channel to a dense PTM. + /// + /// # Errors + /// + /// Returns an error if the Pauli-basis dimension overflows. + pub fn to_ptm(&self) -> Result { + self.to_diagonal_ptm()?.to_ptm() + } + + /// Returns the number of qubits represented by this channel. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Returns the PTM basis ordering. + #[must_use] + pub fn basis_order(&self) -> PtmBasisOrder { + self.basis_order + } + + /// Returns the sparse probability map. + #[must_use] + pub fn probabilities(&self) -> &BTreeMap { + &self.probabilities + } + + /// Returns the probability of a specific Pauli error. + #[must_use] + pub fn probability(&self, pauli: &PauliBitmaskSmall) -> f64 { + self.probabilities.get(pauli).copied().unwrap_or(0.0) + } + + /// Returns the total non-identity probability. + #[must_use] + pub fn total_error_rate(&self) -> f64 { + self.probabilities + .iter() + .filter(|(pauli, _)| !pauli.is_identity()) + .map(|(_, probability)| probability) + .sum() + } +} + +/// Sparse diagonal Pauli transfer matrix. +#[derive(Clone, Debug, PartialEq)] +pub struct DiagonalPtm { + num_qubits: usize, + basis_order: PtmBasisOrder, + fidelities: BTreeMap, +} + +impl DiagonalPtm { + /// Constructs a diagonal PTM after validating term qubit ranges. + /// + /// Missing Pauli terms are treated as zero fidelity. + /// + /// # Errors + /// + /// Returns an error when any term is outside the declared qubit range or + /// any fidelity is non-finite. + pub fn try_new( + num_qubits: usize, + fidelities: BTreeMap, + ) -> Result { + let mut cleaned = BTreeMap::new(); + for (pauli, fidelity) in fidelities { + validate_num_qubits(num_qubits, &pauli)?; + validate_real(fidelity)?; + if fidelity.abs() > DEFAULT_TOLERANCE { + cleaned.insert(pauli, fidelity); + } + } + Ok(Self { + num_qubits, + basis_order: PtmBasisOrder::default(), + fidelities: cleaned, + }) + } + + /// Converts diagonal PTM entries to Pauli-channel probabilities. + /// + /// # Errors + /// + /// Returns an error if the Pauli-basis dimension overflows or the inverse + /// Walsh-Hadamard transform does not produce valid probabilities. + pub fn to_pauli_channel(&self) -> Result { + let basis_len = pauli_basis_len(self.num_qubits)?; + #[allow(clippy::cast_precision_loss)] + let scale = basis_len as f64; + let basis: Vec = (0..basis_len) + .map(|basis_index| basis_bitmask(self.num_qubits, basis_index)) + .collect::>()?; + let mut probabilities = BTreeMap::new(); + for error in &basis { + let probability: f64 = basis + .iter() + .map(|basis_element| { + self.fidelity(basis_element) * commutation_character(error, basis_element) + }) + .sum::() + / scale; + probabilities.insert(error.clone(), probability); + } + PauliChannel::try_new(self.num_qubits, probabilities) + } + + /// Converts a symbolic Pauli-unitary channel expression to diagonal PTM + /// entries. + /// + /// # Errors + /// + /// Returns an error when the expression is not a supported Pauli-unitary + /// mixture or when probabilities are invalid. + pub fn from_channel_expr(channel: &ChannelExpr) -> Result { + PauliChannel::from_channel_expr(channel)?.to_diagonal_ptm() + } + + /// Expands this diagonal PTM into a dense PTM matrix. + /// + /// # Errors + /// + /// Returns an error if the Pauli-basis dimension overflows. + pub fn to_ptm(&self) -> Result { + let basis_len = pauli_basis_len(self.num_qubits)?; + let mut matrix = DMatrix::zeros(basis_len, basis_len); + for basis_idx in 0..basis_len { + let basis = basis_bitmask(self.num_qubits, basis_idx)?; + matrix[(basis_idx, basis_idx)] = self.fidelity(&basis); + } + Ptm::try_new(self.num_qubits, matrix) + } + + /// Returns the number of qubits represented by this diagonal PTM. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Returns the PTM basis ordering. + #[must_use] + pub fn basis_order(&self) -> PtmBasisOrder { + self.basis_order + } + + /// Returns the sparse fidelity map. + #[must_use] + pub fn fidelities(&self) -> &BTreeMap { + &self.fidelities + } + + /// Returns the fidelity for a specific Pauli basis element. + #[must_use] + pub fn fidelity(&self, pauli: &PauliBitmaskSmall) -> f64 { + self.fidelities.get(pauli).copied().unwrap_or(0.0) + } +} + +/// Dense Pauli transfer matrix in the canonical PECOS Pauli basis. +/// +/// PTM entries use the normalized convention +/// `R_ij = (1/d) Tr[P_i E(P_j)]`, where `d = 2^num_qubits`. +/// Rows index output Paulis, columns index input Paulis. +#[derive(Clone, Debug, PartialEq)] +pub struct Ptm { + num_qubits: usize, + basis_order: PtmBasisOrder, + matrix: DMatrix, +} + +impl Ptm { + /// Constructs a dense PTM after validating the structural shape. + /// + /// # Errors + /// + /// Returns an error when `matrix` is not `4^num_qubits x 4^num_qubits` + /// or contains non-finite entries. + pub fn try_new(num_qubits: usize, matrix: DMatrix) -> Result { + let basis_len = pauli_basis_len(num_qubits)?; + if matrix.nrows() != basis_len || matrix.ncols() != basis_len { + return Err(ChannelError::InvalidMatrixShape { + expected_rows: basis_len, + expected_cols: basis_len, + rows: matrix.nrows(), + cols: matrix.ncols(), + }); + } + for value in matrix.iter() { + validate_real(*value)?; + } + Ok(Self { + num_qubits, + basis_order: PtmBasisOrder::default(), + matrix, + }) + } + + /// Constructs the identity channel PTM over `num_qubits`. + /// + /// # Errors + /// + /// Returns an error if the Pauli-basis dimension overflows. + pub fn identity(num_qubits: usize) -> Result { + let basis_len = pauli_basis_len(num_qubits)?; + Self::try_new(num_qubits, DMatrix::identity(basis_len, basis_len)) + } + + /// Constructs a dense PTM from a diagonal PTM. + /// + /// # Errors + /// + /// Returns an error if the Pauli-basis dimension overflows. + pub fn from_diagonal_ptm(diagonal: &DiagonalPtm) -> Result { + diagonal.to_ptm() + } + + /// Constructs a dense PTM from a Pauli channel. + /// + /// # Errors + /// + /// Returns an error if the Pauli-basis dimension overflows. + pub fn from_pauli_channel(channel: &PauliChannel) -> Result { + channel.to_ptm() + } + + /// Constructs the PTM for a unitary conjugation channel. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow or numerical entries have + /// significant imaginary components. + pub fn from_unitary(unitary: &UnitaryRep, num_qubits: usize) -> Result { + let basis_len = pauli_basis_len(num_qubits)?; + let dim = hilbert_dim(num_qubits)?; + #[allow(clippy::cast_precision_loss)] + let dim_f = dim as f64; + let unitary_matrix = to_matrix_with_size(unitary, num_qubits).into_inner(); + if unitary_matrix.nrows() != dim || unitary_matrix.ncols() != dim { + return Err(ChannelError::InvalidMatrixShape { + expected_rows: dim, + expected_cols: dim, + rows: unitary_matrix.nrows(), + cols: unitary_matrix.ncols(), + }); + } + let unitary_adjoint = unitary_matrix.adjoint(); + let basis_matrices = pauli_basis_matrices(num_qubits)?; + let mut matrix = DMatrix::zeros(basis_len, basis_len); + for input_idx in 0..basis_len { + let evolved = &unitary_matrix * &basis_matrices[input_idx] * &unitary_adjoint; + for output_idx in 0..basis_len { + let entry = trace_complex(&(&basis_matrices[output_idx] * &evolved)) / dim_f; + if entry.im.abs() > DEFAULT_TOLERANCE { + return Err(ChannelError::NonRealCoefficient { + value: entry, + tolerance: DEFAULT_TOLERANCE, + }); + } + matrix[(output_idx, input_idx)] = entry.re; + } + } + Self::try_new(num_qubits, matrix) + } + + /// Converts a symbolic channel expression to a dense PTM when supported. + /// + /// This supports unitary, mixed-unitary, amplitude-damping, phase-damping, + /// tensor, and composed channel expressions that can be represented by + /// [`KrausOps`]. Erasure/leakage channels are intentionally rejected until + /// PECOS has an explicit flag or extended-Hilbert-space representation. + /// + /// # Errors + /// + /// Returns an error when the expression is unsupported or structurally + /// invalid. + pub fn from_channel_expr(channel: &ChannelExpr) -> Result { + let num_qubits = channel_num_qubits(channel).max(1); + match channel { + ChannelExpr::Unitary(unitary) => Self::from_unitary(unitary, num_qubits), + ChannelExpr::MixedUnitary(ops) => { + let basis_len = pauli_basis_len(num_qubits)?; + let mut matrix = DMatrix::zeros(basis_len, basis_len); + let mut total_probability = 0.0; + for (probability, unitary) in ops { + validate_probability(*probability, DEFAULT_TOLERANCE)?; + let unitary_ptm = Self::from_unitary(unitary, num_qubits)?; + matrix += unitary_ptm.matrix * *probability; + total_probability += *probability; + } + if (total_probability - 1.0).abs() > DEFAULT_TOLERANCE { + return Err(ChannelError::ProbabilitySum { + sum: total_probability, + tolerance: DEFAULT_TOLERANCE, + }); + } + Self::try_new(num_qubits, matrix) + } + ChannelExpr::AmplitudeDamping { .. } + | ChannelExpr::PhaseDamping { .. } + | ChannelExpr::Tensor(_) + | ChannelExpr::Compose(_) => KrausOps::from_channel_expr(channel)?.to_ptm(), + _ => Err(ChannelError::UnsupportedChannelExpr { + reason: "dense PTM conversion supports unitary, mixed-unitary, amplitude-damping, phase-damping, tensor, and compose channels".to_string(), + }), + } + } + + /// Returns the number of qubits represented by this PTM. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Returns the PTM basis ordering. + #[must_use] + pub fn basis_order(&self) -> PtmBasisOrder { + self.basis_order + } + + /// Returns the dense PTM matrix. + #[must_use] + pub fn matrix(&self) -> &DMatrix { + &self.matrix + } + + /// Consumes this PTM and returns its dense matrix. + #[must_use] + pub fn into_matrix(self) -> DMatrix { + self.matrix + } + + /// Returns one PTM entry by row/output and column/input basis indices. + #[must_use] + pub fn entry(&self, output: usize, input: usize) -> f64 { + self.matrix[(output, input)] + } + + /// Converts this PTM to a Choi matrix. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow or the conversion encounters + /// invalid matrix data. + pub fn to_choi(&self) -> Result { + ChoiMatrix::from_ptm(self) + } + + /// Converts this PTM to Kraus operators through its Choi representation. + /// + /// # Errors + /// + /// Returns an error when the Choi conversion or numerical decomposition + /// fails. + pub fn to_kraus(&self) -> Result { + self.to_choi()?.to_kraus() + } + + /// Converts this PTM to a column-stacked superoperator. + /// + /// # Errors + /// + /// Returns an error when the conversion through matrix units fails. + pub fn to_superop(&self) -> Result { + SuperOp::from_ptm(self) + } + + /// Converts this PTM to a Pauli-basis process matrix. + /// + /// # Errors + /// + /// Returns an error when the conversion through Choi/Kraus form fails. + pub fn to_chi(&self) -> Result { + ChiMatrix::from_ptm(self) + } +} + +/// Concrete Kraus-operator representation of a quantum channel. +/// +/// A Kraus channel applies +/// `E(rho) = sum_k K_k rho K_k†`. +/// Operators are full `2^n x 2^n` matrices using the same little-endian +/// computational-basis convention as [`UnitaryMatrix`](crate::UnitaryMatrix). +#[derive(Clone, Debug, PartialEq)] +pub struct KrausOps { + num_qubits: usize, + operators: Vec>, +} + +impl KrausOps { + /// Constructs a Kraus representation after structural validation. + /// + /// This validates only cheap structural properties: non-empty operator + /// list, matrix shape, and finite entries. Trace-preservation and complete + /// positivity are mathematical properties of the Kraus form; call + /// [`Self::is_trace_preserving_with_tolerance`] when that check is needed. + /// + /// # Errors + /// + /// Returns an error when the operator list is empty, a matrix has the + /// wrong shape, a dimension overflows, or an entry is not finite. + pub fn try_new( + num_qubits: usize, + operators: Vec>, + ) -> Result { + if operators.is_empty() { + return Err(ChannelError::EmptyKrausSet); + } + let dim = hilbert_dim(num_qubits)?; + for operator in &operators { + validate_complex_matrix(operator, dim, dim)?; + } + Ok(Self { + num_qubits, + operators, + }) + } + + /// Constructs a one-operator Kraus representation for a unitary channel. + /// + /// # Errors + /// + /// Returns an error when the embedded unitary matrix has an invalid shape. + pub fn from_unitary(unitary: &UnitaryRep, num_qubits: usize) -> Result { + let operator = to_matrix_with_size(unitary, num_qubits).into_inner(); + Self::try_new(num_qubits, vec![operator]) + } + + /// Converts a symbolic channel expression to Kraus operators when + /// supported. + /// + /// Supported variants are unitary, mixed-unitary, amplitude damping, phase + /// damping, tensor, and compose. Measurement/preparation gate expressions, + /// erasure, and leakage are intentionally rejected because they need + /// instrument or flag semantics beyond a simple same-Hilbert-space Kraus + /// channel. + /// + /// # Errors + /// + /// Returns an error when the expression is unsupported or invalid. + pub fn from_channel_expr(channel: &ChannelExpr) -> Result { + let num_qubits = channel_num_qubits(channel).max(1); + kraus_from_channel_expr_with_size(channel, num_qubits) + } + + /// Converts a symbolic channel expression to Kraus operators embedded in a + /// specific system size. + /// + /// Use this when applying a local channel to a larger simulator state: a + /// channel acting on qubit 3 of a 6-qubit system needs 6-qubit Kraus + /// matrices, not the minimal 4-qubit representation implied by the highest + /// touched qubit. + /// + /// # Errors + /// + /// Returns an error when the expression is unsupported, invalid, or touches + /// a qubit outside `num_qubits`. + pub fn from_channel_expr_with_num_qubits( + channel: &ChannelExpr, + num_qubits: usize, + ) -> Result { + for qubit in channel.qubits() { + if qubit >= num_qubits { + return Err(ChannelError::QubitOutOfRange { num_qubits, qubit }); + } + } + kraus_from_channel_expr_with_size(channel, num_qubits) + } + + /// Converts this Kraus channel to a dense PTM. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow or a PTM entry has a + /// significant imaginary component. + pub fn to_ptm(&self) -> Result { + let basis_len = pauli_basis_len(self.num_qubits)?; + let dim = hilbert_dim(self.num_qubits)?; + #[allow(clippy::cast_precision_loss)] + let dim_f = dim as f64; + let basis_matrices = pauli_basis_matrices(self.num_qubits)?; + let mut matrix = DMatrix::zeros(basis_len, basis_len); + for input_idx in 0..basis_len { + let mut evolved = DMatrix::zeros(dim, dim); + for operator in &self.operators { + evolved += operator * &basis_matrices[input_idx] * operator.adjoint(); + } + for output_idx in 0..basis_len { + let entry = trace_complex(&(&basis_matrices[output_idx] * &evolved)) / dim_f; + if entry.im.abs() > DEFAULT_TOLERANCE { + return Err(ChannelError::NonRealCoefficient { + value: entry, + tolerance: DEFAULT_TOLERANCE, + }); + } + matrix[(output_idx, input_idx)] = entry.re; + } + } + Ptm::try_new(self.num_qubits, matrix) + } + + /// Converts this Kraus channel to a Choi matrix. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn to_choi(&self) -> Result { + ChoiMatrix::from_kraus(self) + } + + /// Converts this Kraus channel to a column-stacked superoperator. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn to_superop(&self) -> Result { + SuperOp::from_kraus(self) + } + + /// Converts this Kraus channel to a Pauli-basis process matrix. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn to_chi(&self) -> Result { + ChiMatrix::from_kraus(self) + } + + /// Converts this Kraus channel to a Stinespring isometry. + /// + /// # Errors + /// + /// Returns an error when the stacked Kraus operators are not an isometry. + pub fn to_stinespring(&self) -> Result { + Stinespring::from_kraus(self) + } + + /// Returns whether `sum_k K_k† K_k = I` within the default tolerance. + #[must_use] + pub fn is_trace_preserving(&self) -> bool { + self.is_trace_preserving_with_tolerance(1e-10) + } + + /// Returns whether `sum_k K_k† K_k = I` within `tolerance`. + #[must_use] + pub fn is_trace_preserving_with_tolerance(&self, tolerance: f64) -> bool { + let Ok(dim) = hilbert_dim(self.num_qubits) else { + return false; + }; + let mut accumulator = DMatrix::zeros(dim, dim); + for operator in &self.operators { + accumulator += operator.adjoint() * operator; + } + let identity = DMatrix::::identity(dim, dim); + matrix_max_abs_diff(&accumulator, &identity) <= tolerance + } + + /// Returns the number of qubits represented by this channel. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Returns the Kraus operators. + #[must_use] + pub fn operators(&self) -> &[DMatrix] { + &self.operators + } + + /// Consumes this value and returns the Kraus operators. + #[must_use] + pub fn into_operators(self) -> Vec> { + self.operators + } +} + +/// Concrete Choi representation of a quantum channel. +/// +/// PECOS stores the unnormalized Choi matrix +/// `J = sum_k vec(K_k) vec(K_k)†`, where `vec` is column-stacking. For a +/// trace-preserving channel on Hilbert dimension `d`, `Tr(J) = d` and +/// `Tr_output(J) = I_input`. +#[derive(Clone, Debug, PartialEq)] +pub struct ChoiMatrix { + num_qubits: usize, + matrix: DMatrix, +} + +impl ChoiMatrix { + /// Constructs a Choi matrix after structural validation. + /// + /// This validates only cheap structural properties: shape and finite + /// entries. Complete positivity and trace preservation are explicit + /// follow-up checks. + /// + /// # Errors + /// + /// Returns an error when the matrix shape is not `4^n x 4^n`, dimensions + /// overflow, or an entry is not finite. + pub fn try_new(num_qubits: usize, matrix: DMatrix) -> Result { + let dim_squared = pauli_basis_len(num_qubits)?; + validate_complex_matrix(&matrix, dim_squared, dim_squared)?; + Ok(Self { num_qubits, matrix }) + } + + /// Converts Kraus operators to a Choi matrix. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn from_kraus(kraus: &KrausOps) -> Result { + let dim = hilbert_dim(kraus.num_qubits)?; + let dim_squared = pauli_basis_len(kraus.num_qubits)?; + let mut matrix = DMatrix::zeros(dim_squared, dim_squared); + for operator in kraus.operators() { + for input_col in 0..dim { + for output_row in 0..dim { + let row = choi_index(dim, output_row, input_col); + let row_value = operator[(output_row, input_col)]; + for input_col_2 in 0..dim { + for output_row_2 in 0..dim { + let col = choi_index(dim, output_row_2, input_col_2); + matrix[(row, col)] += + row_value * operator[(output_row_2, input_col_2)].conj(); + } + } + } + } + } + Self::try_new(kraus.num_qubits, matrix) + } + + /// Constructs a Choi matrix for a unitary channel. + /// + /// # Errors + /// + /// Returns an error when unitary embedding or Choi construction fails. + pub fn from_unitary(unitary: &UnitaryRep, num_qubits: usize) -> Result { + KrausOps::from_unitary(unitary, num_qubits)?.to_choi() + } + + /// Converts a symbolic channel expression to a Choi matrix when supported. + /// + /// # Errors + /// + /// Returns an error when [`KrausOps::from_channel_expr`] rejects the + /// expression or Choi construction fails. + pub fn from_channel_expr(channel: &ChannelExpr) -> Result { + KrausOps::from_channel_expr(channel)?.to_choi() + } + + /// Converts a PTM to a Choi matrix. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn from_ptm(ptm: &Ptm) -> Result { + let dim = hilbert_dim(ptm.num_qubits)?; + let dim_squared = pauli_basis_len(ptm.num_qubits)?; + let mut matrix = DMatrix::zeros(dim_squared, dim_squared); + for input_row in 0..dim { + for input_col in 0..dim { + let mut matrix_unit = DMatrix::zeros(dim, dim); + matrix_unit[(input_row, input_col)] = Complex64::new(1.0, 0.0); + let evolved = apply_ptm_to_operator(ptm, &matrix_unit)?; + for output_row in 0..dim { + for output_col in 0..dim { + matrix[( + choi_index(dim, output_row, input_row), + choi_index(dim, output_col, input_col), + )] = evolved[(output_row, output_col)]; + } + } + } + } + Self::try_new(ptm.num_qubits, matrix) + } + + /// Reconstructs a Choi matrix from complete operator-basis tomography data. + /// + /// `outputs` must contain `d^2` matrices, where `d = 2^num_qubits`. + /// Entry `input_row + input_col * d` is the measured/reconstructed output + /// operator `E(|input_row>], + ) -> Result { + let dim = hilbert_dim(num_qubits)?; + let dim_squared = pauli_basis_len(num_qubits)?; + if outputs.len() != dim_squared { + return Err(ChannelError::InvalidTomographySampleCount { + expected: dim_squared, + actual: outputs.len(), + }); + } + + let mut matrix = DMatrix::zeros(dim_squared, dim_squared); + for input_col in 0..dim { + for input_row in 0..dim { + let output = &outputs[matrix_unit_index(dim, input_row, input_col)]; + validate_complex_matrix(output, dim, dim)?; + for output_row in 0..dim { + for output_col in 0..dim { + matrix[( + choi_index(dim, output_row, input_row), + choi_index(dim, output_col, input_col), + )] = output[(output_row, output_col)]; + } + } + } + } + Self::try_new(num_qubits, matrix) + } + + /// Converts this Choi matrix to a dense PTM. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow or a PTM entry has a + /// significant imaginary component. + pub fn to_ptm(&self) -> Result { + let basis_len = pauli_basis_len(self.num_qubits)?; + let dim = hilbert_dim(self.num_qubits)?; + #[allow(clippy::cast_precision_loss)] + let dim_f = dim as f64; + let basis_matrices = pauli_basis_matrices(self.num_qubits)?; + let mut matrix = DMatrix::zeros(basis_len, basis_len); + for input_idx in 0..basis_len { + let evolved = self.apply_to_operator(&basis_matrices[input_idx])?; + for output_idx in 0..basis_len { + let entry = trace_complex(&(&basis_matrices[output_idx] * &evolved)) / dim_f; + if entry.im.abs() > DEFAULT_TOLERANCE { + return Err(ChannelError::NonRealCoefficient { + value: entry, + tolerance: DEFAULT_TOLERANCE, + }); + } + matrix[(output_idx, input_idx)] = entry.re; + } + } + Ptm::try_new(self.num_qubits, matrix) + } + + /// Converts this Choi matrix to a Kraus representation using SVD. + /// + /// For a valid positive-semidefinite Choi matrix, the singular values are + /// the Choi eigenvalues and the left singular vectors produce a Kraus + /// decomposition. This method reconstructs the Choi matrix from the + /// resulting Kraus operators and rejects inputs that are not positive + /// semidefinite within the requested tolerance. + /// + /// # Errors + /// + /// Returns an error when numerical decomposition fails. + pub fn to_kraus(&self) -> Result { + self.to_kraus_with_tolerance(DEFAULT_TOLERANCE) + } + + /// Converts this Choi matrix to a column-stacked superoperator. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn to_superop(&self) -> Result { + SuperOp::from_choi(self) + } + + /// Converts this Choi matrix to a Pauli-basis process matrix. + /// + /// # Errors + /// + /// Returns an error when conversion through Kraus form fails. + pub fn to_chi(&self) -> Result { + ChiMatrix::from_choi(self) + } + + /// Converts this Choi matrix to Kraus operators with an explicit + /// singular-value cutoff. + /// + /// # Errors + /// + /// Returns an error when the tolerance is invalid, numerical decomposition + /// fails, or the input is not positive semidefinite within tolerance. + pub fn to_kraus_with_tolerance(&self, tolerance: f64) -> Result { + if !tolerance.is_finite() || tolerance < 0.0 { + return Err(ChannelError::DecompositionFailed { + reason: format!("invalid Kraus decomposition tolerance: {tolerance}"), + }); + } + let dim = hilbert_dim(self.num_qubits)?; + let svd = SVD::new(self.matrix.clone(), true, false); + let u = svd.u.ok_or_else(|| ChannelError::DecompositionFailed { + reason: "SVD did not return left singular vectors".to_string(), + })?; + let mut operators = Vec::new(); + for (idx, singular_value) in svd.singular_values.iter().copied().enumerate() { + if singular_value <= tolerance { + continue; + } + let scale = Complex64::new(singular_value.sqrt(), 0.0); + let mut operator = DMatrix::zeros(dim, dim); + for input_col in 0..dim { + for output_row in 0..dim { + operator[(output_row, input_col)] = + u[(choi_index(dim, output_row, input_col), idx)] * scale; + } + } + operators.push(operator); + } + if operators.is_empty() { + operators.push(DMatrix::zeros(dim, dim)); + } + let kraus = KrausOps::try_new(self.num_qubits, operators)?; + let recovered = Self::from_kraus(&kraus)?; + let reconstruction_tolerance = (10.0 * tolerance).max(1e-10); + if matrix_max_abs_diff(recovered.matrix(), &self.matrix) > reconstruction_tolerance { + return Err(ChannelError::DecompositionFailed { + reason: "Choi matrix is not positive semidefinite within tolerance".to_string(), + }); + } + Ok(kraus) + } + + /// Applies the represented channel to an operator matrix. + /// + /// # Errors + /// + /// Returns an error when the input operator shape is invalid. + pub fn apply_to_operator( + &self, + operator: &DMatrix, + ) -> Result, ChannelError> { + let dim = hilbert_dim(self.num_qubits)?; + validate_complex_matrix(operator, dim, dim)?; + let mut out = DMatrix::zeros(dim, dim); + for input_row in 0..dim { + for input_col in 0..dim { + let coefficient = operator[(input_row, input_col)]; + for output_row in 0..dim { + for output_col in 0..dim { + out[(output_row, output_col)] += coefficient + * self.matrix[( + choi_index(dim, output_row, input_row), + choi_index(dim, output_col, input_col), + )]; + } + } + } + } + Ok(out) + } + + /// Returns the output partial trace `Tr_output(J)`. + /// + /// With PECOS's column-stacked Choi convention, this equals the input-space + /// identity for a trace-preserving channel. + /// + /// # Errors + /// + /// Returns an error when the Hilbert-space dimension overflows. + pub fn partial_trace_output(&self) -> Result, ChannelError> { + let dim = hilbert_dim(self.num_qubits)?; + let mut reduced = DMatrix::zeros(dim, dim); + for input_row in 0..dim { + for input_col in 0..dim { + let mut value = Complex64::new(0.0, 0.0); + for output in 0..dim { + value += self.matrix[( + choi_index(dim, output, input_row), + choi_index(dim, output, input_col), + )]; + } + reduced[(input_row, input_col)] = value; + } + } + Ok(reduced) + } + + /// Returns the input partial trace `Tr_input(J)`. + /// + /// With PECOS's column-stacked Choi convention, this equals `E(I)` and + /// therefore equals the output-space identity for a unital channel. + /// + /// # Errors + /// + /// Returns an error when the Hilbert-space dimension overflows. + pub fn partial_trace_input(&self) -> Result, ChannelError> { + let dim = hilbert_dim(self.num_qubits)?; + let mut reduced = DMatrix::zeros(dim, dim); + for output_row in 0..dim { + for output_col in 0..dim { + let mut value = Complex64::new(0.0, 0.0); + for input in 0..dim { + value += self.matrix[( + choi_index(dim, output_row, input), + choi_index(dim, output_col, input), + )]; + } + reduced[(output_row, output_col)] = value; + } + } + Ok(reduced) + } + + /// Returns whether this Choi matrix is positive semidefinite within the + /// default tolerance. + #[must_use] + pub fn is_completely_positive(&self) -> bool { + self.is_completely_positive_with_tolerance(1e-10) + } + + /// Returns whether this Choi matrix is positive semidefinite within + /// `tolerance`. + #[must_use] + pub fn is_completely_positive_with_tolerance(&self, tolerance: f64) -> bool { + self.to_kraus_with_tolerance(tolerance).is_ok() + } + + /// Returns whether this Choi matrix is completely positive and + /// trace-preserving within the default tolerance. + #[must_use] + pub fn is_cptp(&self) -> bool { + self.is_cptp_with_tolerance(1e-10) + } + + /// Returns whether this Choi matrix is completely positive and + /// trace-preserving within `tolerance`. + #[must_use] + pub fn is_cptp_with_tolerance(&self, tolerance: f64) -> bool { + self.is_completely_positive_with_tolerance(tolerance) + && self.is_trace_preserving_with_tolerance(tolerance) + } + + /// Returns whether `E(I) = I` within the default tolerance. + #[must_use] + pub fn is_unital(&self) -> bool { + self.is_unital_with_tolerance(1e-10) + } + + /// Returns whether `E(I) = I` within `tolerance`. + #[must_use] + pub fn is_unital_with_tolerance(&self, tolerance: f64) -> bool { + let Ok(dim) = hilbert_dim(self.num_qubits) else { + return false; + }; + let Ok(reduced) = self.partial_trace_input() else { + return false; + }; + let identity = DMatrix::::identity(dim, dim); + matrix_max_abs_diff(&reduced, &identity) <= tolerance + } + + /// Returns whether `Tr_output(J) = I_input` within the default tolerance. + #[must_use] + pub fn is_trace_preserving(&self) -> bool { + self.is_trace_preserving_with_tolerance(1e-10) + } + + /// Returns whether `Tr_output(J) = I_input` within `tolerance`. + #[must_use] + pub fn is_trace_preserving_with_tolerance(&self, tolerance: f64) -> bool { + let Ok(dim) = hilbert_dim(self.num_qubits) else { + return false; + }; + let Ok(reduced) = self.partial_trace_output() else { + return false; + }; + let identity = DMatrix::::identity(dim, dim); + matrix_max_abs_diff(&reduced, &identity) <= tolerance + } + + /// Returns the number of qubits represented by this Choi matrix. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Returns the dense Choi matrix. + #[must_use] + pub fn matrix(&self) -> &DMatrix { + &self.matrix + } + + /// Consumes this value and returns the dense Choi matrix. + #[must_use] + pub fn into_matrix(self) -> DMatrix { + self.matrix + } +} + +/// Dense column-stacked superoperator representation. +/// +/// `SuperOp` stores the matrix `S` satisfying `vec(E(A)) = S vec(A)`, where +/// `vec` uses column-stacking in PECOS's little-endian computational basis. +#[derive(Clone, Debug, PartialEq)] +pub struct SuperOp { + num_qubits: usize, + matrix: DMatrix, +} + +impl SuperOp { + /// Constructs a superoperator after structural validation. + /// + /// # Errors + /// + /// Returns an error when `matrix` is not `4^n x 4^n` or contains + /// non-finite entries. + pub fn try_new(num_qubits: usize, matrix: DMatrix) -> Result { + let dim_squared = pauli_basis_len(num_qubits)?; + validate_complex_matrix(&matrix, dim_squared, dim_squared)?; + Ok(Self { num_qubits, matrix }) + } + + /// Constructs a superoperator from Kraus operators. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn from_kraus(kraus: &KrausOps) -> Result { + let dim = hilbert_dim(kraus.num_qubits)?; + let dim_squared = pauli_basis_len(kraus.num_qubits)?; + let mut matrix = DMatrix::zeros(dim_squared, dim_squared); + for input_col in 0..dim { + for input_row in 0..dim { + let input_idx = matrix_unit_index(dim, input_row, input_col); + for output_col in 0..dim { + for output_row in 0..dim { + let output_idx = matrix_unit_index(dim, output_row, output_col); + let mut value = Complex64::new(0.0, 0.0); + for operator in kraus.operators() { + value += operator[(output_row, input_row)] + * operator[(output_col, input_col)].conj(); + } + matrix[(output_idx, input_idx)] = value; + } + } + } + } + Self::try_new(kraus.num_qubits, matrix) + } + + /// Constructs a superoperator from a Choi matrix. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn from_choi(choi: &ChoiMatrix) -> Result { + let dim = hilbert_dim(choi.num_qubits)?; + let dim_squared = pauli_basis_len(choi.num_qubits)?; + let mut matrix = DMatrix::zeros(dim_squared, dim_squared); + for input_col in 0..dim { + for input_row in 0..dim { + let input_idx = matrix_unit_index(dim, input_row, input_col); + for output_col in 0..dim { + for output_row in 0..dim { + let output_idx = matrix_unit_index(dim, output_row, output_col); + matrix[(output_idx, input_idx)] = choi.matrix()[( + choi_index(dim, output_row, input_row), + choi_index(dim, output_col, input_col), + )]; + } + } + } + } + Self::try_new(choi.num_qubits, matrix) + } + + /// Constructs a superoperator from a PTM. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn from_ptm(ptm: &Ptm) -> Result { + let dim = hilbert_dim(ptm.num_qubits)?; + let dim_squared = pauli_basis_len(ptm.num_qubits)?; + let mut matrix = DMatrix::zeros(dim_squared, dim_squared); + for input_col in 0..dim { + for input_row in 0..dim { + let mut input = DMatrix::zeros(dim, dim); + input[(input_row, input_col)] = Complex64::new(1.0, 0.0); + let output = apply_ptm_to_operator(ptm, &input)?; + let input_idx = matrix_unit_index(dim, input_row, input_col); + for output_col in 0..dim { + for output_row in 0..dim { + let output_idx = matrix_unit_index(dim, output_row, output_col); + matrix[(output_idx, input_idx)] = output[(output_row, output_col)]; + } + } + } + } + Self::try_new(ptm.num_qubits, matrix) + } + + /// Constructs a superoperator from a supported channel expression. + /// + /// # Errors + /// + /// Returns an error when the expression cannot be represented as Kraus + /// operators. + pub fn from_channel_expr(channel: &ChannelExpr) -> Result { + KrausOps::from_channel_expr(channel)?.to_superop() + } + + /// Converts this superoperator to a Choi matrix. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn to_choi(&self) -> Result { + let dim = hilbert_dim(self.num_qubits)?; + let mut matrix = DMatrix::zeros(self.matrix.nrows(), self.matrix.ncols()); + for input_col in 0..dim { + for input_row in 0..dim { + let input_idx = matrix_unit_index(dim, input_row, input_col); + for output_col in 0..dim { + for output_row in 0..dim { + let output_idx = matrix_unit_index(dim, output_row, output_col); + matrix[( + choi_index(dim, output_row, input_row), + choi_index(dim, output_col, input_col), + )] = self.matrix[(output_idx, input_idx)]; + } + } + } + } + ChoiMatrix::try_new(self.num_qubits, matrix) + } + + /// Converts this superoperator to a PTM. + /// + /// # Errors + /// + /// Returns an error when Choi/PTM conversion fails. + pub fn to_ptm(&self) -> Result { + self.to_choi()?.to_ptm() + } + + /// Converts this superoperator to Kraus operators. + /// + /// # Errors + /// + /// Returns an error when Choi/Kraus conversion fails. + pub fn to_kraus(&self) -> Result { + self.to_choi()?.to_kraus() + } + + /// Returns the composition `self ∘ other`, applying `other` first. + /// + /// # Errors + /// + /// Returns an error when qubit counts differ. + pub fn compose(&self, other: &Self) -> Result { + if self.num_qubits != other.num_qubits { + return Err(ChannelError::QubitCountMismatch { + expected: self.num_qubits, + actual: other.num_qubits, + }); + } + Self::try_new(self.num_qubits, &self.matrix * &other.matrix) + } + + /// Returns the tensor product of two superoperators. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn tensor(&self, other: &Self) -> Result { + Self::try_new( + self.num_qubits + other.num_qubits, + complex_kronecker(&self.matrix, &other.matrix), + ) + } + + /// Returns the number of qubits represented by this superoperator. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Returns the dense superoperator matrix. + #[must_use] + pub fn matrix(&self) -> &DMatrix { + &self.matrix + } + + /// Consumes this value and returns its dense matrix. + #[must_use] + pub fn into_matrix(self) -> DMatrix { + self.matrix + } +} + +/// Pauli-basis process matrix. +/// +/// `ChiMatrix` stores coefficients `chi_ij` in +/// `E(rho) = sum_ij chi_ij P_i rho P_j†`, with the same Pauli basis ordering +/// as [`Ptm`]. +#[derive(Clone, Debug, PartialEq)] +pub struct ChiMatrix { + num_qubits: usize, + basis_order: PtmBasisOrder, + matrix: DMatrix, +} + +impl ChiMatrix { + /// Constructs a chi matrix after structural validation. + /// + /// # Errors + /// + /// Returns an error when `matrix` is not `4^n x 4^n` or contains + /// non-finite entries. + pub fn try_new(num_qubits: usize, matrix: DMatrix) -> Result { + let basis_len = pauli_basis_len(num_qubits)?; + validate_complex_matrix(&matrix, basis_len, basis_len)?; + Ok(Self { + num_qubits, + basis_order: PtmBasisOrder::default(), + matrix, + }) + } + + /// Constructs a chi matrix from Kraus operators. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn from_kraus(kraus: &KrausOps) -> Result { + let basis_len = pauli_basis_len(kraus.num_qubits)?; + let dim = hilbert_dim(kraus.num_qubits)?; + #[allow(clippy::cast_precision_loss)] + let dim_f = dim as f64; + let basis = pauli_basis_matrices(kraus.num_qubits)?; + let mut matrix = DMatrix::zeros(basis_len, basis_len); + for operator in kraus.operators() { + let coefficients: Vec = basis + .iter() + .map(|pauli| trace_complex(&(pauli * operator)) / dim_f) + .collect(); + for row in 0..basis_len { + for col in 0..basis_len { + matrix[(row, col)] += coefficients[row] * coefficients[col].conj(); + } + } + } + Self::try_new(kraus.num_qubits, matrix) + } + + /// Constructs a chi matrix from a Choi matrix. + /// + /// # Errors + /// + /// Returns an error when Choi/Kraus conversion fails. + pub fn from_choi(choi: &ChoiMatrix) -> Result { + choi.to_kraus()?.to_chi() + } + + /// Constructs a chi matrix from a PTM. + /// + /// # Errors + /// + /// Returns an error when PTM/Choi/Kraus conversion fails. + pub fn from_ptm(ptm: &Ptm) -> Result { + ptm.to_kraus()?.to_chi() + } + + /// Constructs a chi matrix from a supported channel expression. + /// + /// # Errors + /// + /// Returns an error when the expression cannot be represented as Kraus + /// operators. + pub fn from_channel_expr(channel: &ChannelExpr) -> Result { + KrausOps::from_channel_expr(channel)?.to_chi() + } + + /// Converts this chi matrix to a Choi matrix. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn to_choi(&self) -> Result { + let dim_squared = pauli_basis_len(self.num_qubits)?; + let basis = pauli_basis_matrices(self.num_qubits)?; + let basis_vectors: Vec> = basis.iter().map(vectorize_matrix).collect(); + let mut matrix = DMatrix::zeros(dim_squared, dim_squared); + for row in 0..dim_squared { + for col in 0..dim_squared { + let coefficient = self.matrix[(row, col)]; + if coefficient.norm() <= DEFAULT_TOLERANCE { + continue; + } + matrix += &basis_vectors[row] * basis_vectors[col].adjoint() * coefficient; + } + } + ChoiMatrix::try_new(self.num_qubits, matrix) + } + + /// Converts this chi matrix to a PTM. + /// + /// # Errors + /// + /// Returns an error when Choi/PTM conversion fails. + pub fn to_ptm(&self) -> Result { + self.to_choi()?.to_ptm() + } + + /// Returns the number of qubits represented by this chi matrix. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Returns the PTM basis ordering. + #[must_use] + pub fn basis_order(&self) -> PtmBasisOrder { + self.basis_order + } + + /// Returns the dense chi matrix. + #[must_use] + pub fn matrix(&self) -> &DMatrix { + &self.matrix + } + + /// Consumes this value and returns its dense matrix. + #[must_use] + pub fn into_matrix(self) -> DMatrix { + self.matrix + } +} + +/// Stinespring isometry representation of a quantum channel. +/// +/// The matrix has shape `(num_kraus * d) x d` and stacks Kraus operators +/// vertically, where `d = 2^num_qubits`. +#[derive(Clone, Debug, PartialEq)] +pub struct Stinespring { + num_qubits: usize, + environment_dim: usize, + isometry: DMatrix, +} + +impl Stinespring { + /// Constructs a Stinespring isometry after structural validation. + /// + /// # Errors + /// + /// Returns an error when shape or isometry validation fails. + pub fn try_new(num_qubits: usize, isometry: DMatrix) -> Result { + let dim = hilbert_dim(num_qubits)?; + if isometry.ncols() != dim || isometry.nrows() == 0 || !isometry.nrows().is_multiple_of(dim) + { + return Err(ChannelError::InvalidMatrixShape { + expected_rows: dim, + expected_cols: dim, + rows: isometry.nrows(), + cols: isometry.ncols(), + }); + } + validate_complex_matrix(&isometry, isometry.nrows(), dim)?; + let identity = DMatrix::::identity(dim, dim); + let gram = isometry.adjoint() * &isometry; + if matrix_max_abs_diff(&gram, &identity) > 1e-10 { + return Err(ChannelError::DecompositionFailed { + reason: "Stinespring matrix is not an isometry".to_string(), + }); + } + Ok(Self { + num_qubits, + environment_dim: isometry.nrows() / dim, + isometry, + }) + } + + /// Constructs a Stinespring isometry by stacking Kraus operators. + /// + /// # Errors + /// + /// Returns an error when the Kraus operators are not trace preserving. + pub fn from_kraus(kraus: &KrausOps) -> Result { + let dim = hilbert_dim(kraus.num_qubits)?; + let mut isometry = DMatrix::zeros(dim * kraus.operators().len(), dim); + for (kraus_idx, operator) in kraus.operators().iter().enumerate() { + for row in 0..dim { + for col in 0..dim { + isometry[(kraus_idx * dim + row, col)] = operator[(row, col)]; + } + } + } + Self::try_new(kraus.num_qubits, isometry) + } + + /// Converts this Stinespring isometry to Kraus operators. + /// + /// # Errors + /// + /// Returns an error when dimensions overflow. + pub fn to_kraus(&self) -> Result { + let dim = hilbert_dim(self.num_qubits)?; + let operators = (0..self.environment_dim) + .map(|kraus_idx| { + DMatrix::from_fn(dim, dim, |row, col| { + self.isometry[(kraus_idx * dim + row, col)] + }) + }) + .collect(); + KrausOps::try_new(self.num_qubits, operators) + } + + /// Converts this Stinespring isometry to a Choi matrix. + /// + /// # Errors + /// + /// Returns an error when Kraus/Choi conversion fails. + pub fn to_choi(&self) -> Result { + self.to_kraus()?.to_choi() + } + + /// Converts this Stinespring isometry to a superoperator. + /// + /// # Errors + /// + /// Returns an error when Kraus/superoperator conversion fails. + pub fn to_superop(&self) -> Result { + self.to_kraus()?.to_superop() + } + + /// Returns the number of qubits represented by this isometry. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Returns the environment dimension, equal to the number of Kraus blocks. + #[must_use] + pub fn environment_dim(&self) -> usize { + self.environment_dim + } + + /// Returns the dense Stinespring isometry. + #[must_use] + pub fn isometry(&self) -> &DMatrix { + &self.isometry + } + + /// Consumes this value and returns its dense isometry. + #[must_use] + pub fn into_isometry(self) -> DMatrix { + self.isometry + } +} + +/// Returns the partial trace of a density matrix over selected qubits. +/// +/// Qubit indexing is little-endian: qubit 0 is the least-significant bit of +/// the computational-basis index. The returned density matrix keeps the +/// untraced qubits in ascending qubit-index order. +/// +/// # Errors +/// +/// Returns an error when the matrix is not `2^num_qubits x 2^num_qubits`, a +/// traced qubit is outside range, or a traced qubit is repeated. +pub fn partial_trace( + matrix: &DMatrix, + num_qubits: usize, + traced_qubits: &[usize], +) -> Result, ChannelError> { + let dim = hilbert_dim(num_qubits)?; + if matrix.nrows() != dim || matrix.ncols() != dim { + return Err(ChannelError::InvalidMatrixShape { + expected_rows: dim, + expected_cols: dim, + rows: matrix.nrows(), + cols: matrix.ncols(), + }); + } + + let mut traced = traced_qubits.to_vec(); + traced.sort_unstable(); + for window in traced.windows(2) { + if window[0] == window[1] { + return Err(ChannelError::DuplicateSubsystem { qubit: window[0] }); + } + } + for &qubit in &traced { + if qubit >= num_qubits { + return Err(ChannelError::QubitOutOfRange { num_qubits, qubit }); + } + } + + let kept: Vec = (0..num_qubits) + .filter(|qubit| traced.binary_search(qubit).is_err()) + .collect(); + let out_dim = 1usize << kept.len(); + let traced_dim = 1usize << traced.len(); + let mut out = DMatrix::zeros(out_dim, out_dim); + for kept_row in 0..out_dim { + for kept_col in 0..out_dim { + let mut value = Complex64::new(0.0, 0.0); + for traced_idx in 0..traced_dim { + let row = embed_subsystem_index(&kept, kept_row, &traced, traced_idx); + let col = embed_subsystem_index(&kept, kept_col, &traced, traced_idx); + value += matrix[(row, col)]; + } + out[(kept_row, kept_col)] = value; + } + } + Ok(out) +} + +/// Returns the computational matrix-unit operator basis. +/// +/// The returned vector has length `d^2`, where `d = 2^num_qubits`. Entry +/// `row + col * d` is the matrix unit `|row> Result>, ChannelError> { + let dim = hilbert_dim(num_qubits)?; + let dim_squared = pauli_basis_len(num_qubits)?; + let mut basis = Vec::with_capacity(dim_squared); + for col in 0..dim { + for row in 0..dim { + let mut matrix = DMatrix::zeros(dim, dim); + matrix[(row, col)] = Complex64::new(1.0, 0.0); + basis.push(matrix); + } + } + Ok(basis) +} + +/// Metadata for one computational matrix-unit tomography input. +/// +/// The input operator is `|row> Result { + let dim = hilbert_dim(num_qubits)?; + let num_inputs = pauli_basis_len(num_qubits)?; + Ok(Self { + num_qubits, + dim, + num_inputs, + }) + } + + /// Returns the number of qubits in the characterized channel. + #[must_use] + pub const fn num_qubits(&self) -> usize { + self.num_qubits + } + + /// Returns the Hilbert-space dimension `2^num_qubits`. + #[must_use] + pub const fn dim(&self) -> usize { + self.dim + } + + /// Returns the number of matrix-unit input operators, `dim^2`. + #[must_use] + pub const fn num_inputs(&self) -> usize { + self.num_inputs + } + + /// Returns the index for matrix unit `|row> Result { + if row >= self.dim || col >= self.dim { + return Err(ChannelError::MatrixUnitOutOfRange { + dim: self.dim, + row, + col, + }); + } + Ok(matrix_unit_index(self.dim, row, col)) + } + + /// Returns metadata for one matrix-unit input. + /// + /// # Errors + /// + /// Returns an error if `index` is outside the design. + pub fn input_metadata(&self, index: usize) -> Result { + if index >= self.num_inputs { + return Err(ChannelError::TomographyInputOutOfRange { + num_inputs: self.num_inputs, + index, + }); + } + Ok(MatrixUnitTomographyInput { + index, + row: index % self.dim, + col: index / self.dim, + }) + } + + /// Returns metadata for all matrix-unit inputs in reconstruction order. + #[must_use] + pub fn input_metadata_all(&self) -> Vec { + (0..self.num_inputs) + .map(|index| MatrixUnitTomographyInput { + index, + row: index % self.dim, + col: index / self.dim, + }) + .collect() + } + + /// Returns the matrix-unit input operator at `index`. + /// + /// # Errors + /// + /// Returns an error if `index` is outside the design. + pub fn input_operator(&self, index: usize) -> Result, ChannelError> { + let input = self.input_metadata(index)?; + let mut matrix = DMatrix::zeros(self.dim, self.dim); + matrix[(input.row, input.col)] = Complex64::new(1.0, 0.0); + Ok(matrix) + } + + /// Returns all matrix-unit input operators in reconstruction order. + #[must_use] + pub fn input_operators(&self) -> Vec> { + (0..self.num_inputs) + .map(|index| { + let row = index % self.dim; + let col = index / self.dim; + let mut matrix = DMatrix::zeros(self.dim, self.dim); + matrix[(row, col)] = Complex64::new(1.0, 0.0); + matrix + }) + .collect() + } + + /// Applies `channel` to each design input in reconstruction order. + /// + /// # Errors + /// + /// Returns an error when `channel` acts on a different number of qubits or + /// if channel application fails. + pub fn simulate_outputs( + &self, + channel: &ChoiMatrix, + ) -> Result>, ChannelError> { + if channel.num_qubits() != self.num_qubits { + return Err(ChannelError::QubitCountMismatch { + expected: self.num_qubits, + actual: channel.num_qubits(), + }); + } + self.input_operators() + .iter() + .map(|operator| channel.apply_to_operator(operator)) + .collect() + } + + /// Reconstructs a Choi matrix from outputs ordered by this design. + /// + /// # Errors + /// + /// Returns an error when output count or shapes do not match the design. + pub fn reconstruct_choi( + &self, + outputs: &[DMatrix], + ) -> Result { + ChoiMatrix::from_matrix_unit_outputs(self.num_qubits, outputs) + } +} + +/// Samples a Hilbert-Schmidt random density matrix on `num_qubits` qubits. +/// +/// This samples a square complex Ginibre matrix `G` and returns +/// `G G† / Tr(G G†)`. The returned matrix uses the same little-endian +/// computational-basis order as PECOS's dense matrix helpers. +/// +/// # Errors +/// +/// Returns an error when the Hilbert-space dimension overflows. +pub fn random_density_matrix( + rng: &mut R, + num_qubits: usize, +) -> Result, ChannelError> +where + R: Rng + ?Sized, +{ + let dim = hilbert_dim(num_qubits)?; + random_density_matrix_with_rank(rng, num_qubits, dim) +} + +/// Samples a Hilbert-Schmidt random density matrix with explicit Ginibre rank. +/// +/// A rank of `1` produces a random pure-state density matrix. Larger ranks +/// produce mixed states from a `dim x rank` complex Ginibre matrix. +/// +/// # Errors +/// +/// Returns an error when the Hilbert-space dimension overflows or `rank == 0`. +pub fn random_density_matrix_with_rank( + rng: &mut R, + num_qubits: usize, + rank: usize, +) -> Result, ChannelError> +where + R: Rng + ?Sized, +{ + if rank == 0 { + return Err(ChannelError::EmptyKrausSet); + } + let dim = hilbert_dim(num_qubits)?; + let ginibre = DMatrix::from_fn(dim, rank, |_, _| standard_complex_normal(rng)); + let mut rho = &ginibre * ginibre.adjoint(); + let trace = trace_complex(&rho).re; + if trace <= 0.0 || !trace.is_finite() { + return Err(ChannelError::DecompositionFailed { + reason: "random density matrix has invalid trace".to_string(), + }); + } + rho /= Complex64::new(trace, 0.0); + Ok(rho) +} + +/// Samples a random CPTP quantum channel in Kraus form. +/// +/// The implementation samples a random Stinespring isometry by QR-decomposing a +/// complex Ginibre matrix of shape `(num_kraus * d) x d`, where +/// `d = 2^num_qubits`, then splits the isometry into `num_kraus` Kraus blocks. +/// The resulting operators satisfy `sum_k K_k† K_k = I` up to numerical +/// precision. +/// +/// # Errors +/// +/// Returns an error when dimensions overflow or `num_kraus == 0`. +pub fn random_quantum_channel( + rng: &mut R, + num_qubits: usize, + num_kraus: usize, +) -> Result +where + R: Rng + ?Sized, +{ + if num_kraus == 0 { + return Err(ChannelError::EmptyKrausSet); + } + let dim = hilbert_dim(num_qubits)?; + let rows = dim + .checked_mul(num_kraus) + .ok_or(ChannelError::DimensionOverflow { num_qubits })?; + let ginibre = DMatrix::from_fn(rows, dim, |_, _| standard_complex_normal(rng)); + let (mut q, r) = ginibre.qr().unpack(); + + for col in 0..dim { + let diagonal = r[(col, col)]; + let norm = diagonal.norm(); + if norm > 0.0 { + let phase = diagonal / norm; + for row in 0..rows { + q[(row, col)] *= phase; + } + } + } + + let operators = (0..num_kraus) + .map(|kraus_idx| { + let start = kraus_idx * dim; + DMatrix::from_fn(dim, dim, |row, col| q[(start + row, col)]) + }) + .collect(); + KrausOps::try_new(num_qubits, operators) +} + +/// Samples a random `num_qubits`-qubit Pauli string. +/// +/// Each qubit independently receives one of `I, X, Y, Z` with equal +/// probability. The all-identity Pauli is allowed. +pub fn random_pauli(rng: &mut R, num_qubits: usize) -> PauliString { + let paulis: Vec = (0..num_qubits) + .map(|_| match rng.random_range(0..4) { + 0 => Pauli::I, + 1 => Pauli::X, + 2 => Pauli::Y, + _ => Pauli::Z, + }) + .collect(); + PauliString::from_paulis(&paulis) +} + +/// Samples one of the 24 single-qubit Clifford gate primitives uniformly. +pub fn random_1q_clifford(rng: &mut R) -> Clifford { + let all = Clifford::all_1q(); + all[rng.random_range(0..all.len())] +} + +/// Samples one of the standard two-qubit Clifford gate primitives uniformly. +pub fn random_2q_clifford(rng: &mut R) -> Clifford { + let all = Clifford::all_2q(); + all[rng.random_range(0..all.len())] +} + +/// Samples one named Clifford gate primitive uniformly from the PECOS Clifford +/// enum. +pub fn random_clifford(rng: &mut R) -> Clifford { + let all = Clifford::all(); + all[rng.random_range(0..all.len())] +} + +fn standard_complex_normal(rng: &mut R) -> Complex64 { + Complex64::new(standard_normal(rng), standard_normal(rng)) +} + +fn standard_normal(rng: &mut R) -> f64 { + loop { + let u1 = rng.random::(); + if u1 > 0.0 { + let u2 = rng.random::(); + return (-2.0 * u1.ln()).sqrt() * (TAU * u2).cos(); + } + } +} + +fn bitmask_from_paulis(paulis: &[Pauli]) -> PauliBitmaskSmall { + let mut out = PauliBitmaskSmall::identity(); + for (qubit, pauli) in paulis.iter().copied().enumerate() { + match pauli { + Pauli::I => {} + Pauli::X => out.x_bits.set_bit(qubit), + Pauli::Y => { + out.x_bits.set_bit(qubit); + out.z_bits.set_bit(qubit); + } + Pauli::Z => out.z_bits.set_bit(qubit), + } + } + out +} + +fn channel_num_qubits(channel: &ChannelExpr) -> usize { + channel.qubits().into_iter().max().map_or(0, |q| q + 1) +} + +fn bitmask_to_pauli_string(num_qubits: usize, pauli: &PauliBitmaskSmall) -> PauliString { + let paulis: Vec = (0..num_qubits) + .map(|qubit| match (pauli.has_x(qubit), pauli.has_z(qubit)) { + (false, false) => Pauli::I, + (true, false) => Pauli::X, + (true, true) => Pauli::Y, + (false, true) => Pauli::Z, + }) + .collect(); + PauliString::from_paulis(&paulis) +} + +fn pauli_basis_matrices(num_qubits: usize) -> Result>, ChannelError> { + let basis_len = pauli_basis_len(num_qubits)?; + let mut out = Vec::with_capacity(basis_len); + for basis_idx in 0..basis_len { + let bitmask = basis_bitmask(num_qubits, basis_idx)?; + let pauli = bitmask_to_pauli_string(num_qubits, &bitmask); + out.push(to_matrix_with_size(&UnitaryRep::Pauli(pauli), num_qubits).into_inner()); + } + Ok(out) +} + +fn apply_ptm_to_operator( + ptm: &Ptm, + operator: &DMatrix, +) -> Result, ChannelError> { + let dim = hilbert_dim(ptm.num_qubits)?; + validate_complex_matrix(operator, dim, dim)?; + #[allow(clippy::cast_precision_loss)] + let dim_f = dim as f64; + let basis_matrices = pauli_basis_matrices(ptm.num_qubits)?; + let mut out = DMatrix::zeros(dim, dim); + for input_idx in 0..basis_matrices.len() { + let coefficient = trace_complex(&(&basis_matrices[input_idx] * operator)) / dim_f; + for (output_idx, basis_matrix) in basis_matrices.iter().enumerate() { + let output_coefficient = coefficient * ptm.entry(output_idx, input_idx); + out += basis_matrix * output_coefficient; + } + } + Ok(out) +} + +fn kraus_from_channel_expr_with_size( + channel: &ChannelExpr, + num_qubits: usize, +) -> Result { + match channel { + ChannelExpr::Unitary(unitary) => KrausOps::from_unitary(unitary, num_qubits), + ChannelExpr::MixedUnitary(ops) => { + let mut total_probability = 0.0; + let mut operators = Vec::with_capacity(ops.len()); + for (probability, unitary) in ops { + validate_probability(*probability, DEFAULT_TOLERANCE)?; + total_probability += *probability; + if *probability > DEFAULT_TOLERANCE { + let scale = Complex64::new(probability.sqrt(), 0.0); + operators.push(to_matrix_with_size(unitary, num_qubits).into_inner() * scale); + } + } + if (total_probability - 1.0).abs() > DEFAULT_TOLERANCE { + return Err(ChannelError::ProbabilitySum { + sum: total_probability, + tolerance: DEFAULT_TOLERANCE, + }); + } + KrausOps::try_new(num_qubits, operators) + } + ChannelExpr::AmplitudeDamping { gamma, qubit } => { + validate_unit_interval(*gamma)?; + let sqrt_survival = (1.0 - gamma).sqrt(); + let sqrt_decay = gamma.sqrt(); + let k0 = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(sqrt_survival, 0.0), + ], + ); + let k1 = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(0.0, 0.0), + Complex64::new(sqrt_decay, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + ], + ); + KrausOps::try_new( + num_qubits, + vec![ + embed_single_qubit_operator(num_qubits, *qubit, &k0)?, + embed_single_qubit_operator(num_qubits, *qubit, &k1)?, + ], + ) + } + ChannelExpr::PhaseDamping { lambda, qubit } => { + validate_unit_interval(*lambda)?; + let sqrt_survival = (1.0 - lambda).sqrt(); + let sqrt_damp = lambda.sqrt(); + let k0 = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(sqrt_survival, 0.0), + ], + ); + let k1 = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(sqrt_damp, 0.0), + ], + ); + KrausOps::try_new( + num_qubits, + vec![ + embed_single_qubit_operator(num_qubits, *qubit, &k0)?, + embed_single_qubit_operator(num_qubits, *qubit, &k1)?, + ], + ) + } + ChannelExpr::Tensor(parts) => { + if parts.is_empty() { + return Err(ChannelError::UnsupportedChannelExpr { + reason: "empty channel tensor has no qubit context".to_string(), + }); + } + validate_disjoint_channel_parts(parts)?; + let mut operators = vec![DMatrix::::identity( + hilbert_dim(num_qubits)?, + hilbert_dim(num_qubits)?, + )]; + for part in parts { + let part_ops = kraus_from_channel_expr_with_size(part, num_qubits)?; + operators = compose_kraus_sets(&operators, part_ops.operators()); + } + KrausOps::try_new(num_qubits, operators) + } + ChannelExpr::Compose(parts) => { + if parts.is_empty() { + return Err(ChannelError::UnsupportedChannelExpr { + reason: "empty channel composition has no qubit context".to_string(), + }); + } + let dim = hilbert_dim(num_qubits)?; + let mut operators = vec![DMatrix::::identity(dim, dim)]; + for part in parts { + let part_ops = kraus_from_channel_expr_with_size(part, num_qubits)?; + operators = compose_kraus_sets(&operators, part_ops.operators()); + } + KrausOps::try_new(num_qubits, operators) + } + ChannelExpr::Gate(_) | ChannelExpr::Erasure { .. } | ChannelExpr::Leakage { .. } => { + Err(ChannelError::UnsupportedChannelExpr { + reason: + "gate instruments, erasure, and leakage need explicit outcome/flag semantics" + .to_string(), + }) + } + } +} + +fn validate_disjoint_channel_parts(parts: &[ChannelExpr]) -> Result<(), ChannelError> { + let mut seen = BTreeSet::new(); + for part in parts { + for qubit in part.qubits() { + if !seen.insert(qubit) { + return Err(ChannelError::DuplicateSubsystem { qubit }); + } + } + } + Ok(()) +} + +fn compose_kraus_sets( + current: &[DMatrix], + next: &[DMatrix], +) -> Vec> { + let mut out = Vec::with_capacity(current.len() * next.len()); + for next_op in next { + for current_op in current { + out.push(next_op * current_op); + } + } + out +} + +fn embed_single_qubit_operator( + num_qubits: usize, + qubit: usize, + local: &DMatrix, +) -> Result, ChannelError> { + if qubit >= num_qubits { + return Err(ChannelError::QubitOutOfRange { num_qubits, qubit }); + } + validate_complex_matrix(local, 2, 2)?; + + let dim = hilbert_dim(num_qubits)?; + let qubit_mask = 1usize << qubit; + let mut out = DMatrix::zeros(dim, dim); + for row in 0..dim { + for col in 0..dim { + if row & !qubit_mask == col & !qubit_mask { + let local_row = usize::from((row & qubit_mask) != 0); + let local_col = usize::from((col & qubit_mask) != 0); + out[(row, col)] = local[(local_row, local_col)]; + } + } + } + Ok(out) +} + +fn choi_index(dim: usize, output_index: usize, input_index: usize) -> usize { + output_index + input_index * dim +} + +fn matrix_unit_index(dim: usize, row: usize, col: usize) -> usize { + row + col * dim +} + +fn vectorize_matrix(matrix: &DMatrix) -> DMatrix { + DMatrix::from_fn(matrix.nrows() * matrix.ncols(), 1, |idx, _| { + let row = idx % matrix.nrows(); + let col = idx / matrix.nrows(); + matrix[(row, col)] + }) +} + +fn complex_kronecker(left: &DMatrix, right: &DMatrix) -> DMatrix { + let rows = left.nrows() * right.nrows(); + let cols = left.ncols() * right.ncols(); + let mut out = DMatrix::zeros(rows, cols); + for left_row in 0..left.nrows() { + for left_col in 0..left.ncols() { + let scale = left[(left_row, left_col)]; + for right_row in 0..right.nrows() { + for right_col in 0..right.ncols() { + out[( + left_row * right.nrows() + right_row, + left_col * right.ncols() + right_col, + )] = scale * right[(right_row, right_col)]; + } + } + } + } + out +} + +fn trace_complex(matrix: &DMatrix) -> Complex64 { + let n = matrix.nrows().min(matrix.ncols()); + (0..n).map(|idx| matrix[(idx, idx)]).sum() +} + +fn hilbert_dim(num_qubits: usize) -> Result { + 2usize + .checked_pow( + num_qubits + .try_into() + .map_err(|_| ChannelError::DimensionOverflow { num_qubits })?, + ) + .ok_or(ChannelError::DimensionOverflow { num_qubits }) +} + +fn embed_subsystem_index( + kept_qubits: &[usize], + kept_index: usize, + traced_qubits: &[usize], + traced_index: usize, +) -> usize { + let mut out = 0usize; + for (bit, qubit) in kept_qubits.iter().copied().enumerate() { + if ((kept_index >> bit) & 1) != 0 { + out |= 1usize << qubit; + } + } + for (bit, qubit) in traced_qubits.iter().copied().enumerate() { + if ((traced_index >> bit) & 1) != 0 { + out |= 1usize << qubit; + } + } + out +} + +fn unitary_rep_to_pauli_bitmask( + num_qubits: usize, + unitary: &UnitaryRep, +) -> Result { + match unitary { + unitary if unitary.is_identity() => Ok(PauliBitmaskSmall::identity()), + UnitaryRep::Pauli(pauli) => { + // Global Pauli phase cancels in the induced channel U rho U†. + pauli_string_to_bitmask(num_qubits, pauli) + } + UnitaryRep::Tensor(parts) => { + let mut out = PauliBitmaskSmall::identity(); + for part in parts { + let part_mask = unitary_rep_to_pauli_bitmask(num_qubits, part)?; + out = out.multiply(&part_mask); + } + Ok(out) + } + _ => Err(ChannelError::UnsupportedChannelExpr { + reason: format!("unitary is not a Pauli operator: {unitary:?}"), + }), + } +} + +fn validate_num_qubits(num_qubits: usize, pauli: &PauliBitmaskSmall) -> Result<(), ChannelError> { + if let Some(qubit) = highest_qubit(pauli) + && qubit >= num_qubits + { + return Err(ChannelError::QubitOutOfRange { num_qubits, qubit }); + } + Ok(()) +} + +fn highest_qubit(pauli: &PauliBitmaskSmall) -> Option { + [ + pauli.x_bits.highest_set_bit(), + pauli.z_bits.highest_set_bit(), + ] + .into_iter() + .flatten() + .max() +} + +fn validate_complex(value: Complex64) -> Result<(), ChannelError> { + if value.re.is_finite() && value.im.is_finite() { + Ok(()) + } else { + Err(ChannelError::InvalidCoefficient { value }) + } +} + +fn validate_complex_matrix( + matrix: &DMatrix, + expected_rows: usize, + expected_cols: usize, +) -> Result<(), ChannelError> { + if matrix.nrows() != expected_rows || matrix.ncols() != expected_cols { + return Err(ChannelError::InvalidMatrixShape { + expected_rows, + expected_cols, + rows: matrix.nrows(), + cols: matrix.ncols(), + }); + } + for value in matrix.iter() { + validate_complex(*value)?; + } + Ok(()) +} + +fn validate_real(value: f64) -> Result<(), ChannelError> { + validate_complex(Complex64::new(value, 0.0)) +} + +fn validate_probability(value: f64, tolerance: f64) -> Result<(), ChannelError> { + validate_real(value)?; + if value < -tolerance { + return Err(ChannelError::InvalidProbability { value, tolerance }); + } + Ok(()) +} + +fn validate_unit_interval(value: f64) -> Result<(), ChannelError> { + validate_probability(value, DEFAULT_TOLERANCE)?; + if value > 1.0 + DEFAULT_TOLERANCE { + return Err(ChannelError::InvalidProbability { + value, + tolerance: DEFAULT_TOLERANCE, + }); + } + Ok(()) +} + +fn matrix_max_abs_diff(a: &DMatrix, b: &DMatrix) -> f64 { + if a.shape() != b.shape() { + return f64::INFINITY; + } + a.iter() + .zip(b.iter()) + .map(|(left, right)| (*left - *right).norm()) + .fold(0.0, f64::max) +} + +fn commutation_character(a: &PauliBitmaskSmall, b: &PauliBitmaskSmall) -> f64 { + if a.commutes_with(b) { 1.0 } else { -1.0 } +} + +#[cfg(test)] +mod tests { + use super::*; + use pecos_core::op; + use pecos_core::unitary; + use pecos_core::{Op, QuarterPhase}; + use pecos_random::PecosRng; + + fn assert_close(a: f64, b: f64) { + assert!((a - b).abs() < 1e-10, "{a} != {b}"); + } + + fn assert_complex_close(a: Complex64, b: Complex64) { + assert_close(a.re, b.re); + assert_close(a.im, b.im); + } + + fn assert_matrix_close(a: &DMatrix, b: &DMatrix) { + assert_eq!(a.shape(), b.shape()); + for row in 0..a.nrows() { + for col in 0..a.ncols() { + assert_close(a[(row, col)], b[(row, col)]); + } + } + } + + fn assert_complex_matrix_close(a: &DMatrix, b: &DMatrix) { + assert_eq!(a.shape(), b.shape()); + for row in 0..a.nrows() { + for col in 0..a.ncols() { + assert_complex_close(a[(row, col)], b[(row, col)]); + } + } + } + + fn apply_kraus_direct(kraus: &KrausOps, operator: &DMatrix) -> DMatrix { + let mut output = DMatrix::zeros(operator.nrows(), operator.ncols()); + for k in kraus.operators() { + output += k * operator * k.adjoint(); + } + output + } + + fn direct_superop_from_kraus(kraus: &KrausOps) -> DMatrix { + let dim = hilbert_dim(kraus.num_qubits()).unwrap(); + let dim_squared = dim * dim; + let mut matrix = DMatrix::zeros(dim_squared, dim_squared); + for input_col in 0..dim { + for input_row in 0..dim { + let mut input = DMatrix::zeros(dim, dim); + input[(input_row, input_col)] = Complex64::new(1.0, 0.0); + let output = apply_kraus_direct(kraus, &input); + let input_idx = matrix_unit_index(dim, input_row, input_col); + for output_col in 0..dim { + for output_row in 0..dim { + let output_idx = matrix_unit_index(dim, output_row, output_col); + matrix[(output_idx, input_idx)] = output[(output_row, output_col)]; + } + } + } + } + matrix + } + + fn direct_ptm_from_kraus(kraus: &KrausOps) -> DMatrix { + let num_qubits = kraus.num_qubits(); + let basis = pauli_basis_matrices(num_qubits).unwrap(); + let dim = hilbert_dim(num_qubits).unwrap(); + #[allow(clippy::cast_precision_loss)] + let dim_f = dim as f64; + let mut matrix = DMatrix::zeros(basis.len(), basis.len()); + for input_idx in 0..basis.len() { + let evolved = apply_kraus_direct(kraus, &basis[input_idx]); + for output_idx in 0..basis.len() { + let entry = trace_complex(&(&basis[output_idx] * &evolved)) / dim_f; + assert!( + entry.im.abs() < 1e-10, + "PTM oracle produced complex entry {entry}" + ); + matrix[(output_idx, input_idx)] = entry.re; + } + } + matrix + } + + fn direct_matrix_unit_outputs(kraus: &KrausOps) -> Vec> { + let dim = hilbert_dim(kraus.num_qubits()).unwrap(); + let mut outputs = Vec::with_capacity(dim * dim); + for input_col in 0..dim { + for input_row in 0..dim { + let mut input = DMatrix::zeros(dim, dim); + input[(input_row, input_col)] = Complex64::new(1.0, 0.0); + outputs.push(apply_kraus_direct(kraus, &input)); + } + } + outputs + } + + fn assert_ptm_entry(ptm: &Ptm, output: &str, input: &str, expected: f64) { + let output_idx = labels(ptm.num_qubits()) + .iter() + .position(|label| label == output) + .unwrap(); + let input_idx = labels(ptm.num_qubits()) + .iter() + .position(|label| label == input) + .unwrap(); + assert_close(ptm.entry(output_idx, input_idx), expected); + } + + fn labels(num_qubits: usize) -> Vec { + (0..pauli_basis_len(num_qubits).unwrap()) + .map(|index| basis_label(num_qubits, index).unwrap()) + .collect() + } + + #[test] + fn one_qubit_basis_order_is_independent_of_pauli_discriminants() { + assert_eq!(Pauli::Z as u8, 0b10); + assert_eq!(Pauli::Y as u8, 0b11); + + assert_eq!(pauli_to_basis_digit(Pauli::I), 0); + assert_eq!(pauli_to_basis_digit(Pauli::X), 1); + assert_eq!(pauli_to_basis_digit(Pauli::Y), 2); + assert_eq!(pauli_to_basis_digit(Pauli::Z), 3); + + assert_eq!(labels(1), ["I", "X", "Y", "Z"]); + } + + #[test] + fn two_qubit_basis_order_is_little_endian_lexicographic() { + assert_eq!( + labels(2), + [ + "II", "IX", "IY", "IZ", "XI", "XX", "XY", "XZ", "YI", "YX", "YY", "YZ", "ZI", "ZX", + "ZY", "ZZ", + ] + ); + } + + #[test] + fn basis_bitmask_and_index_round_trip() { + for num_qubits in 1..=3 { + for index in 0..pauli_basis_len(num_qubits).unwrap() { + let pauli = basis_bitmask(num_qubits, index).unwrap(); + assert_eq!(basis_index(num_qubits, &pauli).unwrap(), index); + } + } + } + + #[test] + fn zero_qubit_channel_representations_are_scalar_identity() { + let ptm = Ptm::identity(0).unwrap(); + assert_eq!(ptm.matrix().shape(), (1, 1)); + assert_close(ptm.entry(0, 0), 1.0); + + let mut probabilities = BTreeMap::new(); + probabilities.insert(PauliBitmaskSmall::identity(), 1.0); + let pauli_channel = PauliChannel::try_new(0, probabilities).unwrap(); + assert_close(pauli_channel.total_error_rate(), 0.0); + assert_matrix_close(pauli_channel.to_ptm().unwrap().matrix(), ptm.matrix()); + + let kraus = KrausOps::try_new( + 0, + vec![DMatrix::from_element(1, 1, Complex64::new(1.0, 0.0))], + ) + .unwrap(); + assert!(kraus.is_trace_preserving()); + + let choi = kraus.to_choi().unwrap(); + assert_eq!(choi.matrix().shape(), (1, 1)); + assert_complex_close(choi.matrix()[(0, 0)], Complex64::new(1.0, 0.0)); + assert!(choi.is_cptp()); + + let superop = kraus.to_superop().unwrap(); + assert_eq!(superop.num_qubits(), 0); + assert_complex_close(superop.matrix()[(0, 0)], Complex64::new(1.0, 0.0)); + + let chi = kraus.to_chi().unwrap(); + assert_complex_close(chi.matrix()[(0, 0)], Complex64::new(1.0, 0.0)); + + let stinespring = kraus.to_stinespring().unwrap(); + assert_eq!(stinespring.environment_dim(), 1); + assert_complex_close(stinespring.isometry()[(0, 0)], Complex64::new(1.0, 0.0)); + } + + #[test] + fn pauli_sum_add_scalar_simplify_and_trace() { + let identity = PauliBitmaskSmall::identity(); + let x0 = PauliBitmaskSmall::x(0); + + let mut a = PauliSum::new(1); + a.add_term(identity.clone(), Complex64::new(2.0, 0.0)) + .unwrap(); + a.add_term(x0.clone(), Complex64::new(1.0, 0.0)).unwrap(); + + let mut b = PauliSum::new(1); + b.add_term(x0.clone(), Complex64::new(-1.0, 0.0)).unwrap(); + b.add_term(PauliBitmaskSmall::z(0), Complex64::new(0.5, 0.0)) + .unwrap(); + + let c = (a + b) * 2.0; + assert_eq!(c.terms().len(), 2); + assert_complex_close(*c.terms().get(&identity).unwrap(), Complex64::new(4.0, 0.0)); + assert_complex_close(c.trace().unwrap(), Complex64::new(8.0, 0.0)); + } + + #[test] + fn pauli_sum_trace_reports_dimension_overflow() { + let sum = PauliSum::new(usize::MAX); + assert_eq!( + sum.trace().unwrap_err(), + ChannelError::DimensionOverflow { + num_qubits: usize::MAX + } + ); + } + + #[test] + fn pauli_sum_try_add_reports_qubit_mismatch() { + let a = PauliSum::new(1); + let b = PauliSum::new(2); + + assert_eq!( + a.try_add(b).unwrap_err(), + ChannelError::QubitCountMismatch { + expected: 1, + actual: 2 + } + ); + } + + #[test] + fn pauli_sum_try_add_merges_terms_and_drops_cancellations() { + let identity = PauliBitmaskSmall::identity(); + let x0 = PauliBitmaskSmall::x(0); + let z0 = PauliBitmaskSmall::z(0); + + let mut a = PauliSum::new(1); + a.add_term(identity.clone(), Complex64::new(2.0, 0.0)) + .unwrap(); + a.add_term(x0.clone(), Complex64::new(1.0, 0.0)).unwrap(); + + let mut b = PauliSum::new(1); + b.add_term(x0.clone(), Complex64::new(-1.0, 0.0)).unwrap(); + b.add_term(z0.clone(), Complex64::new(0.5, 0.0)).unwrap(); + + let sum = a.try_add(b).unwrap(); + assert_eq!(sum.terms().len(), 2); + assert!(sum.terms().get(&x0).is_none()); + assert_complex_close( + *sum.terms().get(&identity).unwrap(), + Complex64::new(2.0, 0.0), + ); + assert_complex_close(*sum.terms().get(&z0).unwrap(), Complex64::new(0.5, 0.0)); + } + + #[test] + fn pauli_sum_from_pauli_string_preserves_phase_as_coefficient() { + let pauli = PauliString::with_phase_and_paulis( + QuarterPhase::PlusI, + vec![(Pauli::X, 0.into()), (Pauli::Z, 1.into())], + ); + let sum = PauliSum::from_pauli_string(2, &pauli).unwrap(); + let label = pauli_string_to_bitmask(2, &pauli).unwrap(); + assert_complex_close(*sum.terms().get(&label).unwrap(), Complex64::new(0.0, 1.0)); + assert_eq!(bitmask_label(2, &label).unwrap(), "ZX"); + } + + #[test] + fn pauli_string_conjugates_pauli_sum_terms() { + let mut sum = PauliSum::new(1); + sum.add_term(PauliBitmaskSmall::x(0), Complex64::new(2.0, 0.0)) + .unwrap(); + sum.add_term(PauliBitmaskSmall::z(0), Complex64::new(3.0, 0.0)) + .unwrap(); + + let conjugated = sum.conjugated_by_pauli_string(&PauliString::z(0)).unwrap(); + assert_complex_close( + *conjugated.terms().get(&PauliBitmaskSmall::x(0)).unwrap(), + Complex64::new(-2.0, 0.0), + ); + assert_complex_close( + *conjugated.terms().get(&PauliBitmaskSmall::z(0)).unwrap(), + Complex64::new(3.0, 0.0), + ); + } + + #[test] + fn pauli_sum_group_commuting_preserves_coefficients() { + let mut sum = PauliSum::new(2); + sum.add_term(PauliBitmaskSmall::x(0), Complex64::new(2.0, 0.0)) + .unwrap(); + sum.add_term(PauliBitmaskSmall::z(0), Complex64::new(3.0, 0.0)) + .unwrap(); + sum.add_term(PauliBitmaskSmall::x(1), Complex64::new(5.0, 0.0)) + .unwrap(); + sum.add_term(PauliBitmaskSmall::z(1), Complex64::new(7.0, 0.0)) + .unwrap(); + + let groups = sum.group_commuting(); + assert_eq!(groups.len(), 2); + assert_eq!( + groups + .iter() + .map(|group| group.terms().len()) + .sum::(), + 4 + ); + + for group in &groups { + for left in group.terms().keys() { + for right in group.terms().keys() { + assert!(left.commutes_with(right)); + } + } + } + + let recovered: BTreeMap<_, _> = groups + .iter() + .flat_map(|group| { + group + .terms() + .iter() + .map(|(pauli, coeff)| (pauli.clone(), *coeff)) + }) + .collect(); + assert_eq!(recovered, sum.terms().clone()); + } + + #[test] + fn qubit_count_errors_fail_construction() { + let mut terms = BTreeMap::new(); + terms.insert(PauliBitmaskSmall::x(2), Complex64::new(1.0, 0.0)); + let err = PauliSum::try_new(2, terms).unwrap_err(); + assert_eq!( + err, + ChannelError::QubitOutOfRange { + num_qubits: 2, + qubit: 2 + } + ); + } + + #[test] + fn one_qubit_pauli_channel_round_trips_through_diagonal_ptm() { + let channel = PauliChannel::one_qubit(0.1, 0.2, 0.3).unwrap(); + let diagonal = channel.to_diagonal_ptm().unwrap(); + + assert_close(diagonal.fidelity(&PauliBitmaskSmall::identity()), 1.0); + assert_close(diagonal.fidelity(&PauliBitmaskSmall::x(0)), 0.0); + assert_close(diagonal.fidelity(&PauliBitmaskSmall::y(0)), 0.2); + assert_close(diagonal.fidelity(&PauliBitmaskSmall::z(0)), 0.4); + + let recovered = diagonal.to_pauli_channel().unwrap(); + assert_close(recovered.probability(&PauliBitmaskSmall::identity()), 0.4); + assert_close(recovered.probability(&PauliBitmaskSmall::x(0)), 0.1); + assert_close(recovered.probability(&PauliBitmaskSmall::y(0)), 0.2); + assert_close(recovered.probability(&PauliBitmaskSmall::z(0)), 0.3); + assert_close(recovered.total_error_rate(), 0.6); + } + + #[test] + fn two_qubit_pauli_channel_round_trips_through_diagonal_ptm() { + let mut probabilities = BTreeMap::new(); + probabilities.insert(PauliBitmaskSmall::identity(), 0.7); + probabilities.insert(PauliBitmaskSmall::x(0), 0.1); + probabilities.insert(PauliBitmaskSmall::z(1), 0.05); + probabilities.insert( + PauliBitmaskSmall::y(0).multiply(&PauliBitmaskSmall::x(1)), + 0.15, + ); + + let channel = PauliChannel::try_new(2, probabilities).unwrap(); + let recovered = channel + .to_diagonal_ptm() + .unwrap() + .to_pauli_channel() + .unwrap(); + + assert_close(recovered.probability(&PauliBitmaskSmall::identity()), 0.7); + assert_close(recovered.probability(&PauliBitmaskSmall::x(0)), 0.1); + assert_close(recovered.probability(&PauliBitmaskSmall::z(1)), 0.05); + assert_close( + recovered.probability(&PauliBitmaskSmall::y(0).multiply(&PauliBitmaskSmall::x(1))), + 0.15, + ); + } + + #[test] + fn pauli_channel_from_pauli_strings_accumulates_sparse_operator_keys() { + use pecos_core::pauli::{I, X, Z}; + + let channel = PauliChannel::from_pauli_strings( + 2, + [(I(), 0.5), (X(0) & Z(1), 0.2), (X(0) & Z(1), 0.3)], + ) + .unwrap(); + + assert_close(channel.probability(&PauliBitmaskSmall::identity()), 0.5); + assert_close( + channel.probability(&PauliBitmaskSmall::x(0).multiply(&PauliBitmaskSmall::z(1))), + 0.5, + ); + + let err = PauliChannel::from_pauli_strings(1, [(Z(2), 1.0)]).unwrap_err(); + assert_eq!( + err, + ChannelError::QubitOutOfRange { + num_qubits: 1, + qubit: 2 + } + ); + } + + #[test] + fn diagonal_ptm_values_are_not_probabilities() { + let mut fidelities = BTreeMap::new(); + fidelities.insert(PauliBitmaskSmall::identity(), 1.0); + fidelities.insert(PauliBitmaskSmall::x(0), -0.5); + fidelities.insert(PauliBitmaskSmall::y(0), 0.25); + fidelities.insert(PauliBitmaskSmall::z(0), 0.75); + + let diagonal = DiagonalPtm::try_new(1, fidelities.clone()).unwrap(); + assert_close(diagonal.fidelity(&PauliBitmaskSmall::x(0)), -0.5); + + let err = PauliChannel::try_new(1, fidelities).unwrap_err(); + assert!(matches!(err, ChannelError::InvalidProbability { .. })); + } + + #[test] + fn pauli_channel_from_pauli_sum_rejects_complex_or_negative_coefficients() { + let mut complex = PauliSum::new(1); + complex + .add_term(PauliBitmaskSmall::identity(), Complex64::new(1.0, 0.1)) + .unwrap(); + assert!(matches!( + PauliChannel::from_pauli_sum(&complex).unwrap_err(), + ChannelError::NonRealCoefficient { .. } + )); + + let mut negative_terms = BTreeMap::new(); + negative_terms.insert(PauliBitmaskSmall::identity(), -0.1); + negative_terms.insert(PauliBitmaskSmall::x(0), 1.1); + assert!(matches!( + PauliChannel::try_new(1, negative_terms).unwrap_err(), + ChannelError::InvalidProbability { .. } + )); + } + + #[test] + fn dense_ptm_identity_channel_is_identity_matrix() { + let ptm = Ptm::identity(2).unwrap(); + assert_eq!(ptm.matrix().nrows(), 16); + assert_eq!(ptm.matrix().ncols(), 16); + for row in 0..16 { + for col in 0..16 { + assert_close(ptm.entry(row, col), if row == col { 1.0 } else { 0.0 }); + } + } + } + + #[test] + fn bit_flip_channel_matches_hand_ptm_and_choi_references() { + use pecos_core::pauli::{I, X}; + + let p = 0.2; + let channel = PauliChannel::from_pauli_strings(1, [(I(), 1.0 - p), (X(0), p)]).unwrap(); + let ptm = channel.to_ptm().unwrap(); + + assert_ptm_entry(&ptm, "I", "I", 1.0); + assert_ptm_entry(&ptm, "X", "X", 1.0); + assert_ptm_entry(&ptm, "Y", "Y", 1.0 - 2.0 * p); + assert_ptm_entry(&ptm, "Z", "Z", 1.0 - 2.0 * p); + + let choi = ptm.to_choi().unwrap(); + let matrix = choi.matrix(); + assert_complex_close(matrix[(0, 0)], Complex64::new(1.0 - p, 0.0)); + assert_complex_close(matrix[(0, 3)], Complex64::new(1.0 - p, 0.0)); + assert_complex_close(matrix[(3, 0)], Complex64::new(1.0 - p, 0.0)); + assert_complex_close(matrix[(3, 3)], Complex64::new(1.0 - p, 0.0)); + assert_complex_close(matrix[(1, 1)], Complex64::new(p, 0.0)); + assert_complex_close(matrix[(1, 2)], Complex64::new(p, 0.0)); + assert_complex_close(matrix[(2, 1)], Complex64::new(p, 0.0)); + assert_complex_close(matrix[(2, 2)], Complex64::new(p, 0.0)); + } + + #[test] + fn depolarizing_channel_matches_hand_diagonal_ptm_reference() { + use pecos_core::pauli::{I, X, Y, Z}; + + let p = 0.1; + let channel = PauliChannel::from_pauli_strings( + 1, + [ + (I(), 1.0 - p), + (X(0), p / 3.0), + (Y(0), p / 3.0), + (Z(0), p / 3.0), + ], + ) + .unwrap(); + let ptm = channel.to_ptm().unwrap(); + let non_identity_fidelity = 1.0 - 4.0 * p / 3.0; + + assert_ptm_entry(&ptm, "I", "I", 1.0); + assert_ptm_entry(&ptm, "X", "X", non_identity_fidelity); + assert_ptm_entry(&ptm, "Y", "Y", non_identity_fidelity); + assert_ptm_entry(&ptm, "Z", "Z", non_identity_fidelity); + } + + #[test] + fn dense_ptm_unitary_conjugation_known_one_qubit_cliffords() { + let h = Ptm::from_unitary(&unitary::H(0), 1).unwrap(); + assert_ptm_entry(&h, "I", "I", 1.0); + assert_ptm_entry(&h, "Z", "X", 1.0); + assert_ptm_entry(&h, "Y", "Y", -1.0); + assert_ptm_entry(&h, "X", "Z", 1.0); + + let s = Ptm::from_unitary(&unitary::SZ(0), 1).unwrap(); + assert_ptm_entry(&s, "I", "I", 1.0); + assert_ptm_entry(&s, "Y", "X", 1.0); + assert_ptm_entry(&s, "X", "Y", -1.0); + assert_ptm_entry(&s, "Z", "Z", 1.0); + + let x = Ptm::from_unitary(&unitary::X(0), 1).unwrap(); + assert_ptm_entry(&x, "I", "I", 1.0); + assert_ptm_entry(&x, "X", "X", 1.0); + assert_ptm_entry(&x, "Y", "Y", -1.0); + assert_ptm_entry(&x, "Z", "Z", -1.0); + } + + #[test] + fn dense_ptm_qubit_order_matches_unitary_matrix_little_endian() { + let x0 = Ptm::from_unitary(&(unitary::X(0) & unitary::I(1)), 2).unwrap(); + assert_ptm_entry(&x0, "IZ", "IZ", -1.0); + assert_ptm_entry(&x0, "ZI", "ZI", 1.0); + + let x1 = Ptm::from_unitary(&(unitary::I(0) & unitary::X(1)), 2).unwrap(); + assert_ptm_entry(&x1, "IZ", "IZ", 1.0); + assert_ptm_entry(&x1, "ZI", "ZI", -1.0); + } + + #[test] + fn invalid_dense_ptm_shape_fails_construction() { + let err = Ptm::try_new(1, DMatrix::zeros(3, 3)).unwrap_err(); + assert!(matches!(err, ChannelError::InvalidMatrixShape { .. })); + } + + #[test] + fn channel_expr_pauli_channel_conversions_handle_common_constructors() { + let Op::Channel(expr) = op::Depolarizing(0.3, 0) else { + panic!("expected channel"); + }; + let channel = PauliChannel::from_channel_expr(&expr).unwrap(); + assert_close(channel.probability(&PauliBitmaskSmall::identity()), 0.7); + assert_close(channel.probability(&PauliBitmaskSmall::x(0)), 0.1); + assert_close(channel.probability(&PauliBitmaskSmall::y(0)), 0.1); + assert_close(channel.probability(&PauliBitmaskSmall::z(0)), 0.1); + + let diagonal = DiagonalPtm::from_channel_expr(&expr).unwrap(); + let dense = Ptm::from_channel_expr(&expr).unwrap(); + assert_close(dense.entry(0, 0), 1.0); + assert_close( + dense.entry(1, 1), + diagonal.fidelity(&PauliBitmaskSmall::x(0)), + ); + } + + #[test] + fn kraus_unitary_ptm_matches_direct_unitary_ptm() { + let kraus = KrausOps::from_unitary(&unitary::H(0), 1).unwrap(); + assert_eq!(kraus.num_qubits(), 1); + assert_eq!(kraus.operators().len(), 1); + assert!(kraus.is_trace_preserving()); + + let from_kraus = kraus.to_ptm().unwrap(); + let direct = Ptm::from_unitary(&unitary::H(0), 1).unwrap(); + assert_matrix_close(from_kraus.matrix(), direct.matrix()); + } + + #[test] + fn kraus_mixed_unitary_ptm_matches_pauli_channel_ptm() { + let Op::Channel(expr) = op::Depolarizing(0.3, 0) else { + panic!("expected channel"); + }; + let kraus = KrausOps::from_channel_expr(&expr).unwrap(); + assert_eq!(kraus.operators().len(), 4); + assert!(kraus.is_trace_preserving()); + + let from_kraus = kraus.to_ptm().unwrap(); + let from_pauli = PauliChannel::from_channel_expr(&expr) + .unwrap() + .to_ptm() + .unwrap(); + assert_matrix_close(from_kraus.matrix(), from_pauli.matrix()); + } + + #[test] + fn amplitude_damping_kraus_and_ptm_have_known_values() { + let gamma = 0.25; + let Op::Channel(expr) = op::AmplitudeDamping(gamma, 0) else { + panic!("expected channel"); + }; + let kraus = KrausOps::from_channel_expr(&expr).unwrap(); + assert_eq!(kraus.operators().len(), 2); + assert!(kraus.is_trace_preserving()); + + let ptm = Ptm::from_channel_expr(&expr).unwrap(); + assert_ptm_entry(&ptm, "I", "I", 1.0); + assert_ptm_entry(&ptm, "Z", "I", gamma); + assert_ptm_entry(&ptm, "X", "X", (1.0 - gamma).sqrt()); + assert_ptm_entry(&ptm, "Y", "Y", (1.0 - gamma).sqrt()); + assert_ptm_entry(&ptm, "Z", "Z", 1.0 - gamma); + } + + #[test] + fn phase_damping_kraus_and_ptm_have_known_values() { + let lambda = 0.36; + let Op::Channel(expr) = op::PhaseDamping(lambda, 0) else { + panic!("expected channel"); + }; + let kraus = KrausOps::from_channel_expr(&expr).unwrap(); + assert_eq!(kraus.operators().len(), 2); + assert!(kraus.is_trace_preserving()); + + let ptm = Ptm::from_channel_expr(&expr).unwrap(); + assert_ptm_entry(&ptm, "I", "I", 1.0); + assert_ptm_entry(&ptm, "X", "X", (1.0 - lambda).sqrt()); + assert_ptm_entry(&ptm, "Y", "Y", (1.0 - lambda).sqrt()); + assert_ptm_entry(&ptm, "Z", "Z", 1.0); + assert_ptm_entry(&ptm, "Z", "I", 0.0); + } + + #[test] + fn kraus_tensor_and_compose_channels_are_trace_preserving() { + let tensor = ChannelExpr::Tensor(vec![ + ChannelExpr::AmplitudeDamping { + gamma: 0.2, + qubit: 0, + }, + ChannelExpr::PhaseDamping { + lambda: 0.3, + qubit: 1, + }, + ]); + let tensor_kraus = KrausOps::from_channel_expr(&tensor).unwrap(); + assert_eq!(tensor_kraus.num_qubits(), 2); + assert_eq!(tensor_kraus.operators().len(), 4); + assert!(tensor_kraus.is_trace_preserving()); + + let compose = ChannelExpr::Compose(vec![ + ChannelExpr::AmplitudeDamping { + gamma: 0.2, + qubit: 0, + }, + ChannelExpr::PhaseDamping { + lambda: 0.3, + qubit: 0, + }, + ]); + let compose_kraus = KrausOps::from_channel_expr(&compose).unwrap(); + assert_eq!(compose_kraus.num_qubits(), 1); + assert_eq!(compose_kraus.operators().len(), 4); + assert!(compose_kraus.is_trace_preserving()); + } + + #[test] + fn kraus_tensor_rejects_manually_constructed_overlapping_subsystems() { + let tensor = ChannelExpr::Tensor(vec![ + pecos_core::channel::BitFlip(0.1, 0), + pecos_core::channel::Dephasing(0.2, 0), + ]); + + assert!(matches!( + KrausOps::from_channel_expr(&tensor), + Err(ChannelError::DuplicateSubsystem { qubit: 0 }) + )); + } + + #[test] + fn kraus_tensor_and_compose_reject_empty_manual_exprs() { + let tensor = ChannelExpr::Tensor(Vec::new()); + let compose = ChannelExpr::Compose(Vec::new()); + + assert!(matches!( + KrausOps::from_channel_expr(&tensor), + Err(ChannelError::UnsupportedChannelExpr { .. }) + )); + assert!(matches!( + KrausOps::from_channel_expr(&compose), + Err(ChannelError::UnsupportedChannelExpr { .. }) + )); + } + + #[test] + fn kraus_from_channel_expr_can_embed_in_larger_system() { + let expr = pecos_core::channel::BitFlip(0.25, 2); + let kraus = KrausOps::from_channel_expr_with_num_qubits(&expr, 3).unwrap(); + + assert_eq!(kraus.num_qubits(), 3); + assert!(kraus.is_trace_preserving()); + assert!(matches!( + KrausOps::from_channel_expr_with_num_qubits(&expr, 2), + Err(ChannelError::QubitOutOfRange { + num_qubits: 2, + qubit: 2 + }) + )); + } + + #[test] + fn pauli_channel_conversion_ignores_global_pauli_phase() { + let pauli = PauliString::from_paulis_with_phase(QuarterPhase::PlusI, &[Pauli::X]); + let expr = ChannelExpr::Unitary(UnitaryRep::Pauli(pauli)); + + let channel = PauliChannel::from_channel_expr(&expr).unwrap(); + assert_close(channel.probability(&PauliBitmaskSmall::x(0)), 1.0); + + let dense = Ptm::from_channel_expr(&expr).unwrap(); + assert_ptm_entry(&dense, "X", "X", 1.0); + assert_ptm_entry(&dense, "Y", "Y", -1.0); + assert_ptm_entry(&dense, "Z", "Z", -1.0); + } + + #[test] + fn pauli_channel_rejects_non_pauli_channel_conversion() { + let Op::Channel(expr) = op::AmplitudeDamping(0.1, 0) else { + panic!("expected channel"); + }; + assert!(matches!( + PauliChannel::from_channel_expr(&expr).unwrap_err(), + ChannelError::UnsupportedChannelExpr { .. } + )); + assert!(Ptm::from_channel_expr(&expr).is_ok()); + } + + #[test] + fn kraus_rejects_channels_with_non_kraus_semantics() { + let Op::Gate(gate) = op::MZ(0) else { + panic!("expected gate"); + }; + let gate_expr = ChannelExpr::Gate(gate); + assert!(matches!( + KrausOps::from_channel_expr(&gate_expr).unwrap_err(), + ChannelError::UnsupportedChannelExpr { .. } + )); + + let Op::Channel(erasure) = op::Erasure(0.1, 0) else { + panic!("expected channel"); + }; + assert!(matches!( + KrausOps::from_channel_expr(&erasure).unwrap_err(), + ChannelError::UnsupportedChannelExpr { .. } + )); + + let Op::Channel(leakage) = op::Leakage(0.1, 0) else { + panic!("expected channel"); + }; + assert!(matches!( + KrausOps::from_channel_expr(&leakage).unwrap_err(), + ChannelError::UnsupportedChannelExpr { .. } + )); + } + + #[test] + fn choi_identity_uses_column_stacked_kraus_convention() { + let choi = ChoiMatrix::from_unitary(&unitary::I(0), 1).unwrap(); + assert_eq!(choi.num_qubits(), 1); + assert_eq!(choi.matrix().shape(), (4, 4)); + assert!(choi.is_trace_preserving()); + assert!(choi.is_completely_positive()); + assert!(choi.is_cptp()); + assert!(choi.is_unital()); + assert_complex_close(trace_complex(choi.matrix()), Complex64::new(2.0, 0.0)); + + let mut expected = DMatrix::zeros(4, 4); + expected[(0, 0)] = Complex64::new(1.0, 0.0); + expected[(0, 3)] = Complex64::new(1.0, 0.0); + expected[(3, 0)] = Complex64::new(1.0, 0.0); + expected[(3, 3)] = Complex64::new(1.0, 0.0); + assert_complex_matrix_close(choi.matrix(), &expected); + + let identity = DMatrix::identity(2, 2); + assert_complex_matrix_close(&choi.partial_trace_output().unwrap(), &identity); + assert_complex_matrix_close(&choi.partial_trace_input().unwrap(), &identity); + } + + #[test] + fn choi_tomography_helpers_classify_basic_channels() { + let zero = ChoiMatrix::try_new(1, DMatrix::zeros(4, 4)).unwrap(); + assert!(zero.is_completely_positive()); + assert!(!zero.is_trace_preserving()); + assert!(!zero.is_cptp()); + assert!(!zero.is_unital()); + + let Op::Channel(expr) = op::AmplitudeDamping(0.25, 0) else { + panic!("expected channel"); + }; + let damping = ChoiMatrix::from_channel_expr(&expr).unwrap(); + assert!(damping.is_completely_positive()); + assert!(damping.is_trace_preserving()); + assert!(damping.is_cptp()); + assert!(!damping.is_unital()); + + let mut expected_trace_input = DMatrix::zeros(2, 2); + expected_trace_input[(0, 0)] = Complex64::new(1.25, 0.0); + expected_trace_input[(1, 1)] = Complex64::new(0.75, 0.0); + assert_complex_matrix_close( + &damping.partial_trace_input().unwrap(), + &expected_trace_input, + ); + } + + #[test] + fn choi_transpose_map_is_trace_preserving_unital_but_not_cp() { + let mut transpose_choi = DMatrix::zeros(4, 4); + transpose_choi[(0, 0)] = Complex64::new(1.0, 0.0); + transpose_choi[(1, 2)] = Complex64::new(1.0, 0.0); + transpose_choi[(2, 1)] = Complex64::new(1.0, 0.0); + transpose_choi[(3, 3)] = Complex64::new(1.0, 0.0); + let choi = ChoiMatrix::try_new(1, transpose_choi).unwrap(); + + assert!(choi.is_trace_preserving()); + assert!(choi.is_unital()); + assert!(!choi.is_completely_positive()); + assert!(!choi.is_cptp()); + } + + #[test] + fn choi_ptm_round_trip_for_depolarizing_channel() { + let Op::Channel(expr) = op::Depolarizing(0.3, 0) else { + panic!("expected channel"); + }; + let ptm = Ptm::from_channel_expr(&expr).unwrap(); + let choi = ptm.to_choi().unwrap(); + assert!(choi.is_trace_preserving()); + + let recovered = choi.to_ptm().unwrap(); + assert_matrix_close(recovered.matrix(), ptm.matrix()); + } + + #[test] + fn choi_round_trips_amplitude_damping_through_kraus_and_ptm() { + let Op::Channel(expr) = op::AmplitudeDamping(0.25, 0) else { + panic!("expected channel"); + }; + let kraus = KrausOps::from_channel_expr(&expr).unwrap(); + let choi = kraus.to_choi().unwrap(); + assert!(choi.is_trace_preserving()); + + let ptm_from_kraus = kraus.to_ptm().unwrap(); + let ptm_from_choi = choi.to_ptm().unwrap(); + assert_matrix_close(ptm_from_choi.matrix(), ptm_from_kraus.matrix()); + + let recovered_kraus = choi.to_kraus().unwrap(); + assert!(recovered_kraus.is_trace_preserving()); + let recovered_ptm = recovered_kraus.to_ptm().unwrap(); + assert_matrix_close(recovered_ptm.matrix(), ptm_from_kraus.matrix()); + } + + #[test] + fn ptm_to_kraus_round_trip_for_unitary_channel() { + let ptm = Ptm::from_unitary(&unitary::H(0), 1).unwrap(); + let kraus = ptm.to_kraus().unwrap(); + assert!(kraus.is_trace_preserving()); + + let recovered = kraus.to_ptm().unwrap(); + assert_matrix_close(recovered.matrix(), ptm.matrix()); + } + + #[test] + fn superop_identity_round_trips_through_channel_representations() { + let kraus = KrausOps::from_unitary(&unitary::I(0), 1).unwrap(); + let superop = SuperOp::from_kraus(&kraus).unwrap(); + let identity = DMatrix::::identity(4, 4); + assert_complex_matrix_close(superop.matrix(), &identity); + + let choi = superop.to_choi().unwrap(); + let expected_choi = ChoiMatrix::from_kraus(&kraus).unwrap(); + assert_complex_matrix_close(choi.matrix(), expected_choi.matrix()); + + let ptm = superop.to_ptm().unwrap(); + assert_matrix_close(ptm.matrix(), Ptm::identity(1).unwrap().matrix()); + } + + #[test] + fn superop_choi_round_trip_for_amplitude_damping() { + let Op::Channel(expr) = op::AmplitudeDamping(0.25, 0) else { + panic!("expected channel"); + }; + let kraus = KrausOps::from_channel_expr(&expr).unwrap(); + let choi = kraus.to_choi().unwrap(); + + let superop = SuperOp::from_choi(&choi).unwrap(); + let recovered = superop.to_choi().unwrap(); + + assert_complex_matrix_close(recovered.matrix(), choi.matrix()); + assert_matrix_close( + superop.to_ptm().unwrap().matrix(), + kraus.to_ptm().unwrap().matrix(), + ); + } + + #[test] + fn superop_ptm_round_trip_for_depolarizing() { + let Op::Channel(expr) = op::Depolarizing(0.3, 0) else { + panic!("expected channel"); + }; + let ptm = Ptm::from_channel_expr(&expr).unwrap(); + + let superop = SuperOp::from_ptm(&ptm).unwrap(); + let recovered = superop.to_ptm().unwrap(); + + assert_matrix_close(recovered.matrix(), ptm.matrix()); + } + + #[test] + fn random_channels_match_direct_kraus_oracles_for_small_systems() { + for (num_qubits, num_kraus, seed) in [(1, 1, 11), (1, 3, 12), (2, 2, 21), (2, 4, 22)] { + let mut rng = PecosRng::seed_from_u64(seed); + let kraus = random_quantum_channel(&mut rng, num_qubits, num_kraus).unwrap(); + assert!(kraus.is_trace_preserving()); + + let superop = kraus.to_superop().unwrap(); + assert_complex_matrix_close(superop.matrix(), &direct_superop_from_kraus(&kraus)); + + let ptm = kraus.to_ptm().unwrap(); + assert_matrix_close(ptm.matrix(), &direct_ptm_from_kraus(&kraus)); + + let choi = kraus.to_choi().unwrap(); + let outputs = direct_matrix_unit_outputs(&kraus); + let reconstructed = ChoiMatrix::from_matrix_unit_outputs(num_qubits, &outputs).unwrap(); + assert_complex_matrix_close(choi.matrix(), reconstructed.matrix()); + + for input in matrix_unit_basis(num_qubits).unwrap() { + assert_complex_matrix_close( + &choi.apply_to_operator(&input).unwrap(), + &apply_kraus_direct(&kraus, &input), + ); + } + + let stinespring_superop = kraus.to_stinespring().unwrap().to_superop().unwrap(); + assert_complex_matrix_close(stinespring_superop.matrix(), superop.matrix()); + } + } + + #[test] + fn three_qubit_random_channel_matches_direct_oracles() { + let mut rng = PecosRng::seed_from_u64(1234); + let kraus = random_quantum_channel(&mut rng, 3, 2).unwrap(); + assert!(kraus.is_trace_preserving()); + + let superop = kraus.to_superop().unwrap(); + assert_eq!(superop.matrix().shape(), (64, 64)); + assert_complex_matrix_close(superop.matrix(), &direct_superop_from_kraus(&kraus)); + + let ptm = kraus.to_ptm().unwrap(); + assert_eq!(ptm.matrix().shape(), (64, 64)); + assert_matrix_close(ptm.matrix(), &direct_ptm_from_kraus(&kraus)); + + let choi = kraus.to_choi().unwrap(); + assert_eq!(choi.matrix().shape(), (64, 64)); + assert!(choi.is_cptp()); + assert_complex_matrix_close( + &choi.partial_trace_output().unwrap(), + &DMatrix::identity(8, 8), + ); + } + + #[test] + fn superop_compose_and_tensor_follow_matrix_semantics() { + let x = KrausOps::from_unitary(&unitary::X(0), 1) + .unwrap() + .to_superop() + .unwrap(); + let xx = x.compose(&x).unwrap(); + assert_complex_matrix_close(xx.matrix(), &DMatrix::::identity(4, 4)); + + let identity = KrausOps::from_unitary(&unitary::I(0), 1) + .unwrap() + .to_superop() + .unwrap(); + let tensor = identity.tensor(&identity).unwrap(); + assert_eq!(tensor.num_qubits(), 2); + assert_complex_matrix_close(tensor.matrix(), &DMatrix::::identity(16, 16)); + + assert_eq!( + identity.compose(&tensor).unwrap_err(), + ChannelError::QubitCountMismatch { + expected: 1, + actual: 2 + } + ); + } + + #[test] + fn stinespring_try_new_rejects_non_isometric_matrix() { + let err = Stinespring::try_new( + 1, + DMatrix::from_diagonal_element(2, 2, Complex64::new(2.0, 0.0)), + ) + .unwrap_err(); + + assert!(matches!( + err, + ChannelError::DecompositionFailed { reason } if reason.contains("not an isometry") + )); + } + + #[test] + fn chi_matrix_is_diagonal_for_pauli_mixture() { + let expr = ChannelExpr::MixedUnitary(vec![(0.7, unitary::I(0)), (0.3, unitary::X(0))]); + let chi = ChiMatrix::from_channel_expr(&expr).unwrap(); + let identity = basis_index(1, &PauliBitmaskSmall::identity()).unwrap(); + let x = basis_index(1, &PauliBitmaskSmall::x(0)).unwrap(); + + assert_complex_close(chi.matrix()[(identity, identity)], Complex64::new(0.7, 0.0)); + assert_complex_close(chi.matrix()[(x, x)], Complex64::new(0.3, 0.0)); + for row in 0..chi.matrix().nrows() { + for col in 0..chi.matrix().ncols() { + if (row, col) != (identity, identity) && (row, col) != (x, x) { + assert!(chi.matrix()[(row, col)].norm() < 1e-10); + } + } + } + + let recovered = chi.to_ptm().unwrap(); + let expected = Ptm::from_channel_expr(&expr).unwrap(); + assert_matrix_close(recovered.matrix(), expected.matrix()); + } + + #[test] + fn chi_matrix_amplitude_damping_has_off_diagonal_terms_and_matches_ptm() { + let Op::Channel(expr) = op::AmplitudeDamping(0.25, 0) else { + panic!("expected channel"); + }; + let kraus = KrausOps::from_channel_expr(&expr).unwrap(); + let chi = ChiMatrix::from_kraus(&kraus).unwrap(); + + let has_off_diagonal = (0..chi.matrix().nrows()).any(|row| { + (0..chi.matrix().ncols()) + .any(|col| row != col && chi.matrix()[(row, col)].norm() > 1e-10) + }); + assert!( + has_off_diagonal, + "amplitude damping should have off-diagonal chi entries" + ); + + let recovered = chi.to_ptm().unwrap(); + let expected = Ptm::from_channel_expr(&expr).unwrap(); + assert_matrix_close(recovered.matrix(), expected.matrix()); + } + + #[test] + fn chi_choi_round_trip_for_amplitude_damping() { + let Op::Channel(expr) = op::AmplitudeDamping(0.25, 0) else { + panic!("expected channel"); + }; + let kraus = KrausOps::from_channel_expr(&expr).unwrap(); + let chi = ChiMatrix::from_kraus(&kraus).unwrap(); + + let recovered = chi.to_choi().unwrap().to_chi().unwrap(); + + assert_complex_matrix_close(recovered.matrix(), chi.matrix()); + } + + #[test] + fn stinespring_round_trips_trace_preserving_kraus_channels() { + let Op::Channel(expr) = op::AmplitudeDamping(0.25, 0) else { + panic!("expected channel"); + }; + let kraus = KrausOps::from_channel_expr(&expr).unwrap(); + let stinespring = kraus.to_stinespring().unwrap(); + assert_eq!(stinespring.num_qubits(), 1); + assert_eq!(stinespring.environment_dim(), kraus.operators().len()); + + let recovered = stinespring.to_kraus().unwrap(); + let expected_choi = kraus.to_choi().unwrap(); + let recovered_choi = recovered.to_choi().unwrap(); + assert_complex_matrix_close(recovered_choi.matrix(), expected_choi.matrix()); + } + + #[test] + fn invalid_choi_shape_fails_construction() { + let err = ChoiMatrix::try_new(1, DMatrix::zeros(2, 2)).unwrap_err(); + assert!(matches!(err, ChannelError::InvalidMatrixShape { .. })); + } + + #[test] + fn choi_to_kraus_rejects_non_positive_choi_matrix() { + let mut matrix = DMatrix::zeros(4, 4); + matrix[(0, 0)] = Complex64::new(1.0, 0.0); + matrix[(3, 3)] = Complex64::new(-1.0, 0.0); + let choi = ChoiMatrix::try_new(1, matrix).unwrap(); + + assert!(matches!( + choi.to_kraus().unwrap_err(), + ChannelError::DecompositionFailed { .. } + )); + } + + #[test] + fn choi_to_kraus_rejects_invalid_tolerance() { + let choi = ChoiMatrix::from_unitary(&unitary::I(0), 1).unwrap(); + + assert!(matches!( + choi.to_kraus_with_tolerance(f64::NAN).unwrap_err(), + ChannelError::DecompositionFailed { .. } + )); + assert!(matches!( + choi.to_kraus_with_tolerance(-1e-12).unwrap_err(), + ChannelError::DecompositionFailed { .. } + )); + } + + #[test] + fn matrix_unit_basis_uses_column_stacked_order() { + let basis = matrix_unit_basis(1).unwrap(); + assert_eq!(basis.len(), 4); + for (idx, (row, col)) in [(0, 0), (1, 0), (0, 1), (1, 1)].into_iter().enumerate() { + let mut expected = DMatrix::zeros(2, 2); + expected[(row, col)] = Complex64::new(1.0, 0.0); + assert_complex_matrix_close(&basis[idx], &expected); + } + } + + #[test] + fn process_tomography_design_exposes_matrix_unit_order() { + let design = ProcessTomographyDesign::matrix_unit(1).unwrap(); + assert_eq!(design.num_qubits(), 1); + assert_eq!(design.dim(), 2); + assert_eq!(design.num_inputs(), 4); + assert_eq!(design.input_index(0, 0).unwrap(), 0); + assert_eq!(design.input_index(1, 0).unwrap(), 1); + assert_eq!(design.input_index(0, 1).unwrap(), 2); + assert_eq!(design.input_index(1, 1).unwrap(), 3); + + let metadata = design.input_metadata_all(); + assert_eq!( + metadata, + vec![ + MatrixUnitTomographyInput { + index: 0, + row: 0, + col: 0 + }, + MatrixUnitTomographyInput { + index: 1, + row: 1, + col: 0 + }, + MatrixUnitTomographyInput { + index: 2, + row: 0, + col: 1 + }, + MatrixUnitTomographyInput { + index: 3, + row: 1, + col: 1 + }, + ] + ); + + let from_design = design.input_operators(); + let from_free_function = matrix_unit_basis(1).unwrap(); + for (actual, expected) in from_design.iter().zip(from_free_function.iter()) { + assert_complex_matrix_close(actual, expected); + } + } + + #[test] + fn process_tomography_design_reconstructs_channel_outputs() { + let Op::Channel(expr) = op::AmplitudeDamping(0.25, 0) else { + panic!("expected channel"); + }; + let expected = ChoiMatrix::from_channel_expr(&expr).unwrap(); + let design = ProcessTomographyDesign::matrix_unit(1).unwrap(); + let outputs = design.simulate_outputs(&expected).unwrap(); + let reconstructed = design.reconstruct_choi(&outputs).unwrap(); + + assert_complex_matrix_close(reconstructed.matrix(), expected.matrix()); + assert!(reconstructed.is_cptp()); + assert!(!reconstructed.is_unital()); + } + + #[test] + fn process_tomography_design_reconstructs_two_qubit_tensor_channel() { + let tensor = ChannelExpr::Tensor(vec![ + pecos_core::channel::AmplitudeDamping(0.25, 0), + pecos_core::channel::PhaseDamping(0.4, 1), + ]); + let kraus = KrausOps::from_channel_expr(&tensor).unwrap(); + let expected = kraus.to_choi().unwrap(); + let design = ProcessTomographyDesign::matrix_unit(2).unwrap(); + + let outputs = direct_matrix_unit_outputs(&kraus); + let reconstructed = design.reconstruct_choi(&outputs).unwrap(); + assert_complex_matrix_close(reconstructed.matrix(), expected.matrix()); + + let simulated_outputs = design.simulate_outputs(&expected).unwrap(); + for (direct, simulated) in outputs.iter().zip(simulated_outputs.iter()) { + assert_complex_matrix_close(simulated, direct); + } + } + + #[test] + fn process_tomography_design_rejects_invalid_inputs() { + let design = ProcessTomographyDesign::matrix_unit(1).unwrap(); + assert!(matches!( + design.input_metadata(4).unwrap_err(), + ChannelError::TomographyInputOutOfRange { + num_inputs: 4, + index: 4 + } + )); + assert!(matches!( + design.input_index(2, 0).unwrap_err(), + ChannelError::MatrixUnitOutOfRange { + dim: 2, + row: 2, + col: 0 + } + )); + + let two_qubit_channel = ChoiMatrix::from_unitary(&unitary::I(0), 2).unwrap(); + assert!(matches!( + design.simulate_outputs(&two_qubit_channel).unwrap_err(), + ChannelError::QubitCountMismatch { + expected: 1, + actual: 2 + } + )); + } + + #[test] + fn choi_reconstructs_identity_from_matrix_unit_outputs() { + let inputs = matrix_unit_basis(1).unwrap(); + let reconstructed = ChoiMatrix::from_matrix_unit_outputs(1, &inputs).unwrap(); + let expected = ChoiMatrix::from_unitary(&unitary::I(0), 1).unwrap(); + + assert_complex_matrix_close(reconstructed.matrix(), expected.matrix()); + assert!(reconstructed.is_cptp()); + assert!(reconstructed.is_unital()); + } + + #[test] + fn choi_reconstructs_amplitude_damping_from_matrix_unit_outputs() { + let Op::Channel(expr) = op::AmplitudeDamping(0.25, 0) else { + panic!("expected channel"); + }; + let expected = ChoiMatrix::from_channel_expr(&expr).unwrap(); + let outputs: Vec> = matrix_unit_basis(1) + .unwrap() + .iter() + .map(|operator| expected.apply_to_operator(operator).unwrap()) + .collect(); + + let reconstructed = ChoiMatrix::from_matrix_unit_outputs(1, &outputs).unwrap(); + assert_complex_matrix_close(reconstructed.matrix(), expected.matrix()); + assert!(reconstructed.is_cptp()); + assert!(!reconstructed.is_unital()); + } + + #[test] + fn choi_tomography_rejects_bad_sample_count_and_shape() { + let err = ChoiMatrix::from_matrix_unit_outputs(1, &[]).unwrap_err(); + assert_eq!( + err, + ChannelError::InvalidTomographySampleCount { + expected: 4, + actual: 0 + } + ); + + let bad_outputs = vec![DMatrix::zeros(2, 2); 3] + .into_iter() + .chain(std::iter::once(DMatrix::zeros(3, 3))) + .collect::>(); + assert!(matches!( + ChoiMatrix::from_matrix_unit_outputs(1, &bad_outputs).unwrap_err(), + ChannelError::InvalidMatrixShape { .. } + )); + } + + #[test] + fn partial_trace_of_bell_state_is_maximally_mixed() { + let half = Complex64::new(0.5, 0.0); + let mut rho = DMatrix::zeros(4, 4); + rho[(0, 0)] = half; + rho[(0, 3)] = half; + rho[(3, 0)] = half; + rho[(3, 3)] = half; + + let reduced = partial_trace(&rho, 2, &[1]).unwrap(); + assert_eq!(reduced.shape(), (2, 2)); + assert_complex_close(reduced[(0, 0)], half); + assert_complex_close(reduced[(1, 1)], half); + assert_complex_close(reduced[(0, 1)], Complex64::new(0.0, 0.0)); + assert_complex_close(reduced[(1, 0)], Complex64::new(0.0, 0.0)); + } + + #[test] + fn partial_trace_respects_little_endian_qubit_ordering() { + let mut rho = DMatrix::zeros(4, 4); + rho[(1, 1)] = Complex64::new(1.0, 0.0); // |q1=0, q0=1><...| + + let keep_q0 = partial_trace(&rho, 2, &[1]).unwrap(); + assert_complex_close(keep_q0[(0, 0)], Complex64::new(0.0, 0.0)); + assert_complex_close(keep_q0[(1, 1)], Complex64::new(1.0, 0.0)); + + let keep_q1 = partial_trace(&rho, 2, &[0]).unwrap(); + assert_complex_close(keep_q1[(0, 0)], Complex64::new(1.0, 0.0)); + assert_complex_close(keep_q1[(1, 1)], Complex64::new(0.0, 0.0)); + } + + #[test] + fn random_density_matrix_is_normalized_hermitian_and_reproducible() { + let mut rng = PecosRng::seed_from_u64(123); + let rho = random_density_matrix(&mut rng, 2).unwrap(); + assert_eq!(rho.shape(), (4, 4)); + assert_complex_close(trace_complex(&rho), Complex64::new(1.0, 0.0)); + assert_complex_matrix_close(&rho, &rho.adjoint()); + + let mut same_seed = PecosRng::seed_from_u64(123); + let same = random_density_matrix(&mut same_seed, 2).unwrap(); + assert_complex_matrix_close(&rho, &same); + + let mut different_seed = PecosRng::seed_from_u64(456); + let different = random_density_matrix(&mut different_seed, 2).unwrap(); + assert_ne!(rho, different); + } + + #[test] + fn random_density_matrix_rank_one_is_pure() { + let mut rng = PecosRng::seed_from_u64(123); + let rho = random_density_matrix_with_rank(&mut rng, 2, 1).unwrap(); + let purity = trace_complex(&(&rho * &rho)).re; + assert_close(purity, 1.0); + + assert!(matches!( + random_density_matrix_with_rank(&mut rng, 1, 0).unwrap_err(), + ChannelError::EmptyKrausSet + )); + } + + #[test] + fn random_density_matrix_three_qubit_rank_limited_is_stable() { + let mut rng = PecosRng::seed_from_u64(789); + let rho = random_density_matrix_with_rank(&mut rng, 3, 3).unwrap(); + assert_eq!(rho.shape(), (8, 8)); + assert_complex_close(trace_complex(&rho), Complex64::new(1.0, 0.0)); + assert_complex_matrix_close(&rho, &rho.adjoint()); + + let purity = trace_complex(&(&rho * &rho)); + assert!(purity.im.abs() < 1e-10); + assert!( + purity.re > 1.0 / 8.0 - 1e-10 && purity.re <= 1.0 + 1e-10, + "unexpected 3-qubit density-matrix purity: {purity}" + ); + } + + #[test] + fn random_quantum_channel_is_cptp_and_reproducible() { + let mut rng = PecosRng::seed_from_u64(123); + let channel = random_quantum_channel(&mut rng, 1, 3).unwrap(); + assert_eq!(channel.operators().len(), 3); + assert!(channel.is_trace_preserving()); + assert!(channel.to_choi().unwrap().is_cptp()); + + let mut same_seed = PecosRng::seed_from_u64(123); + let same = random_quantum_channel(&mut same_seed, 1, 3).unwrap(); + for (left, right) in channel.operators().iter().zip(same.operators()) { + assert_complex_matrix_close(left, right); + } + + let mut different_seed = PecosRng::seed_from_u64(456); + let different = random_quantum_channel(&mut different_seed, 1, 3).unwrap(); + assert_ne!(channel, different); + } + + #[test] + fn random_quantum_channel_rejects_zero_kraus_count() { + let mut rng = PecosRng::seed_from_u64(123); + assert!(matches!( + random_quantum_channel(&mut rng, 1, 0).unwrap_err(), + ChannelError::EmptyKrausSet + )); + } + + #[test] + fn random_helpers_return_values_from_expected_sets() { + let mut rng = PecosRng::seed_from_u64(123); + let pauli = random_pauli(&mut rng, 5); + assert!(pauli.qubits().into_iter().all(|q| q < 5)); + + let c1 = random_1q_clifford(&mut rng); + assert!(Clifford::all_1q().contains(&c1)); + + let c2 = random_2q_clifford(&mut rng); + assert!(Clifford::all_2q().contains(&c2)); + + let c = random_clifford(&mut rng); + assert!(Clifford::all().contains(&c)); + } +} diff --git a/crates/pecos-quantum/src/circuit_display.rs b/crates/pecos-quantum/src/circuit_display.rs index 796672126..63a2b69fc 100644 --- a/crates/pecos-quantum/src/circuit_display.rs +++ b/crates/pecos-quantum/src/circuit_display.rs @@ -74,7 +74,9 @@ fn gate_symbol(gate_type: GateType) -> &'static str { GateType::QFree => "QF", GateType::I | GateType::Idle => "I", GateType::MeasCrosstalkGlobalPayload | GateType::MeasCrosstalkLocalPayload => "XT", + GateType::Channel => "Ch", GateType::Custom => "?", + GateType::TrackedPauliMeta => "TP", } } @@ -207,6 +209,7 @@ fn gate_color(gate_type: GateType) -> CellColor { | GateType::QAlloc | GateType::QFree | GateType::Custom + | GateType::Channel | GateType::MeasCrosstalkGlobalPayload | GateType::MeasCrosstalkLocalPayload | GateType::CX @@ -217,7 +220,8 @@ fn gate_color(gate_type: GateType) -> CellColor { | GateType::U | GateType::R1XY | GateType::RXXRYYRZZ - | GateType::U2q => CellColor::None, + | GateType::U2q + | GateType::TrackedPauliMeta => CellColor::None, } } diff --git a/crates/pecos-quantum/src/dag_circuit.rs b/crates/pecos-quantum/src/dag_circuit.rs index a052ce158..7086a6dcf 100644 --- a/crates/pecos-quantum/src/dag_circuit.rs +++ b/crates/pecos-quantum/src/dag_circuit.rs @@ -17,7 +17,7 @@ //! This module provides [`DagCircuit`], a directed acyclic graph representation //! of quantum circuits where nodes are gates and edges are qubit wires. //! -//! The design follows HUGR and Qiskit's `DAGCircuit`: edges represent qubit wires +//! The design follows a wire-edge DAG model: edges represent qubit wires //! flowing between gates, not just abstract dependencies. use std::collections::{BTreeMap, BTreeSet}; @@ -126,10 +126,14 @@ impl DagTraversalIndex { self.max_qubit + 1 } - /// Returns the number of gates. + /// Returns the number of gate nodes in the traversal index. + /// + /// A batched DAG node still contributes one node here. Use + /// [`DagCircuit::gate_count`] when you need the number of individual gates + /// represented by batched gate nodes. #[inline] #[must_use] - pub fn num_gates(&self) -> usize { + pub fn num_gate_nodes(&self) -> usize { self.topo_order.len() } @@ -313,79 +317,11 @@ impl TraversalWorkBuffers { } } -/// A handle returned by measurement operations, allowing metadata to be attached. -/// -/// This follows the simulator pattern where measurements break the chain, -/// but still allows attaching metadata via `.meta()`. -/// -/// # Example -/// ``` -/// use pecos_quantum::{DagCircuit, Attribute}; -/// -/// let mut circuit = DagCircuit::new(); -/// circuit.mz(&[0]).meta("basis", Attribute::String("Z".into())); -/// circuit.h(&[1]); // continue building -/// ``` -pub struct MeasureHandle<'a> { - circuit: &'a mut DagCircuit, - node: usize, -} - -impl MeasureHandle<'_> { - /// Returns the node ID of this measurement. - #[inline] - #[must_use] - pub fn node(&self) -> usize { - self.node - } - - /// Add metadata to this measurement. - /// - /// Returns `()` to break the chain, matching simulator behavior. - pub fn meta(self, key: &str, value: impl Into) { - self.circuit.set_gate_attr(self.node, key, value.into()); - } -} - -/// Handle returned by preparation operations to allow metadata attachment. -/// -/// This handle breaks the method chain (unlike regular gates), -/// but still allows attaching metadata via `.meta()`. -/// -/// # Example -/// ``` -/// use pecos_quantum::{DagCircuit, Attribute}; -/// -/// let mut circuit = DagCircuit::new(); -/// circuit.pz(&[0]).meta("reason", Attribute::String("reset".into())); -/// circuit.h(&[1]); // continue building -/// ``` -pub struct PrepHandle<'a> { - circuit: &'a mut DagCircuit, - node: usize, -} - -impl PrepHandle<'_> { - /// Returns the node ID of this preparation. - #[inline] - #[must_use] - pub fn node(&self) -> usize { - self.node - } - - /// Add metadata to this preparation. - /// - /// Returns `()` to break the chain. - pub fn meta(self, key: &str, value: impl Into) { - self.circuit.set_gate_attr(self.node, key, value.into()); - } -} - /// A directed acyclic graph representation of a quantum circuit. /// /// Each node in the DAG represents a quantum gate. Edges represent qubit wires /// flowing between gates - each edge is labeled with the [`QubitId`] it carries. -/// This design follows HUGR and Qiskit's `DAGCircuit`. +/// This design follows a wire-edge DAG model. /// /// For a two-qubit gate like CX, there are two incoming edges (one per qubit) /// and two outgoing edges. @@ -416,6 +352,72 @@ impl PrepHandle<'_> { /// assert_eq!(circuit.gate_count(), 2); /// assert_eq!(circuit.wire_count(), 1); /// ``` +/// A measurement reference returned by [`DagCircuit::mz`]. +/// +/// Carries both the DAG node index and the qubit that was measured. +/// Dereferences to `usize` (the node index) for use in detector/observable +/// definitions. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct MeasRef { + /// DAG node index. + pub node: usize, + /// Qubit that was measured. + pub qubit: QubitId, +} + +impl std::ops::Deref for MeasRef { + type Target = usize; + fn deref(&self) -> &usize { + &self.node + } +} + +impl From for usize { + fn from(m: MeasRef) -> usize { + m.node + } +} + +/// The role of a Pauli annotation in the circuit. +/// +/// All three kinds track the same thing -- whether a Pauli string flips due to +/// faults. The difference is how the answer is read out and what it means. +#[derive(Debug, Clone)] +pub enum AnnotationKind { + /// Stabilizer check: the Pauli should be deterministic (flip = error detected). + /// Stores measurement node indices for classical readout via XOR, plus + /// optional coordinates for visualization/matching. + Detector { + measurement_nodes: Vec, + coords: Vec, + }, + /// Logical observable: the Pauli's flip determines a logical outcome. + /// Stores measurement node indices for classical readout via XOR. + Observable { measurement_nodes: Vec }, + /// Tracked Pauli: no measurement readout. + /// Position is determined by a `TrackedPauliMeta` node in the DAG. + TrackedPauli, +} + +/// A unified Pauli annotation: detectors, observables, and tracked Paulis +/// are all Pauli strings tracked for flipping via backward propagation. +/// +/// - **Detectors** are stabilizer checks that should be +1 (noiseless). +/// Their Pauli is Z on the measured qubits. +/// - **Observables** are logical operators read out via measurements. +/// Their Pauli is Z on the measured qubits. +/// - **Tracked Paulis** are arbitrary Pauli strings with no measurement readout. +/// Their Pauli is user-specified and their position comes from a meta-gate node. +#[derive(Debug, Clone)] +pub struct PauliAnnotation { + /// The Pauli string being tracked. + pub pauli: pecos_core::PauliString, + /// What role this annotation plays. + pub kind: AnnotationKind, + /// Optional label. + pub label: Option, +} + #[derive(Debug, Clone)] pub struct DagCircuit { /// The underlying DAG structure. @@ -430,6 +432,10 @@ pub struct DagCircuit { last_node: Option, /// Maximum qubit index seen so far (updated incrementally on gate addition). max_qubit: usize, + /// Unified Pauli annotations (detectors, observables, and tracked Paulis). + annotations: Vec, + /// Measurement labels (`node_index` → label). + measurement_labels: BTreeMap, } impl DagCircuit { @@ -443,6 +449,8 @@ impl DagCircuit { qubit_heads: BTreeMap::new(), last_node: None, max_qubit: 0, + annotations: Vec::new(), + measurement_labels: BTreeMap::new(), } } @@ -462,12 +470,14 @@ impl DagCircuit { qubit_heads: BTreeMap::new(), last_node: None, max_qubit: 0, + annotations: Vec::new(), + measurement_labels: BTreeMap::new(), } } // ==================== Gate operations ==================== - /// Adds a gate to the circuit. + /// Adds a validated gate to the circuit. /// /// Returns the node index of the newly added gate. /// The gate is not connected to any other gates yet - use [`connect`](Self::connect) @@ -476,7 +486,27 @@ impl DagCircuit { /// # Arguments /// /// * `gate` - The gate to add + /// + /// # Panics + /// + /// Panics if [`Gate::validate`] rejects the gate payload. Use + /// [`try_add_gate`](Self::try_add_gate) for fallible insertion. pub fn add_gate(&mut self, gate: Gate) -> usize { + self.try_add_gate(gate) + .unwrap_or_else(|err| panic!("Invalid gate: {err}")) + } + + /// Try to add a validated gate to the circuit. + /// + /// # Errors + /// + /// Returns an error if [`Gate::validate`] rejects the gate payload. + pub fn try_add_gate(&mut self, gate: Gate) -> Result { + gate.validate()?; + Ok(self.add_gate_unchecked(gate)) + } + + fn add_gate_unchecked(&mut self, gate: Gate) -> usize { let node_idx = self.dag.add_node(); // Ensure gates vector is large enough if node_idx >= self.gates.len() { @@ -525,8 +555,20 @@ impl DagCircuit { } /// Returns the number of gates in the circuit. + /// + /// Batched gate nodes count by individual gate. For example, a node carrying + /// `Gate::cx(&[(0, 1), (2, 3)])` contributes two gates. #[must_use] pub fn gate_count(&self) -> usize { + self.gates.iter().flatten().map(Gate::num_gates).sum() + } + + /// Returns the number of gate nodes stored in the DAG. + /// + /// A batched node carrying `Gate::cx(&[(0, 1), (2, 3)])` contributes one + /// gate node and two gates. + #[must_use] + pub fn gate_node_count(&self) -> usize { self.dag.node_count() } @@ -753,7 +795,8 @@ impl DagCircuit { .iter() .flatten() .filter(|g| g.is_single_qubit()) - .count() + .map(Gate::num_gates) + .sum() } /// Returns the count of two-qubit gates. @@ -763,7 +806,8 @@ impl DagCircuit { .iter() .flatten() .filter(|g| g.is_two_qubit()) - .count() + .map(Gate::num_gates) + .sum() } /// Returns the count of gates of a specific type. @@ -773,7 +817,8 @@ impl DagCircuit { .iter() .flatten() .filter(|g| g.gate_type == gate_type) - .count() + .map(Gate::num_gates) + .sum() } // ==================== Topological operations ==================== @@ -1532,9 +1577,7 @@ impl DagCircuit { // -------------------- Measurement and preparation -------------------- // - // Measurements return MeasureHandle which allows .meta() but breaks - // further chaining, matching simulator behavior where mz returns - // MeasurementResult instead of &mut Self. + // Measurements return Vec (lightweight Copy handles). // Preparations return &mut Self and are chainable. /// Measure qubit(s) in the Z basis. @@ -1546,13 +1589,46 @@ impl DagCircuit { /// use pecos_quantum::DagCircuit; /// /// let mut circuit = DagCircuit::new(); - /// circuit.h(&[0]).mz(&[0, 1]); + /// circuit.h(&[0]); + /// let nodes = circuit.mz(&[0, 1]); + /// assert_eq!(nodes.len(), 2); /// ``` - pub fn mz(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { - for &q in qubits { - self.add_gate_auto_wire(Gate::mz(&[q])); - } - self + pub fn mz(&mut self, qubits: &[impl Into + Copy]) -> Vec { + qubits + .iter() + .map(|&q| { + let qubit = q.into(); + let node = self.add_gate_auto_wire(Gate::mz(&[qubit])); + MeasRef { node, qubit } + }) + .collect() + } + + /// Measure qubits and label them. + /// + /// Labels are stored on the circuit and flow through to the sampler output. + pub fn mz_labeled(&mut self, entries: &[(impl Into + Copy, &str)]) -> Vec { + entries + .iter() + .map(|&(q, label)| { + let qubit = q.into(); + let node = self.add_gate_auto_wire(Gate::mz(&[qubit])); + let mref = MeasRef { node, qubit }; + self.set_measurement_label(node, label); + mref + }) + .collect() + } + + /// Set a label on a measurement node. + pub fn set_measurement_label(&mut self, node: usize, label: &str) { + self.measurement_labels.insert(node, label.to_string()); + } + + /// Get the label of a measurement node, if any. + #[must_use] + pub fn measurement_label(&self, node: usize) -> Option<&str> { + self.measurement_labels.get(&node).map(String::as_str) } /// Measure and free qubit(s) (destructive measurement). @@ -1563,6 +1639,224 @@ impl DagCircuit { self } + // ======================================================================== + // Detector and Observable Annotations + // ======================================================================== + + /// Annotate a detector: a set of measurements whose XOR should be + /// deterministic in the noiseless case. + /// + /// The Pauli string is automatically Z on the measured qubits. + /// + /// Returns the annotation index. + pub fn detector(&mut self, measurements: &[impl Into + Copy]) -> usize { + let meas_nodes: Vec = measurements.iter().map(|&m| m.into()).collect(); + let pauli = self.pauli_from_measurement_nodes(&meas_nodes); + let idx = self.annotations.len(); + self.annotations.push(PauliAnnotation { + pauli, + kind: AnnotationKind::Detector { + measurement_nodes: meas_nodes, + coords: Vec::new(), + }, + label: None, + }); + idx + } + + /// Annotate a labeled detector. + pub fn detector_labeled( + &mut self, + label: &str, + measurements: &[impl Into + Copy], + ) -> usize { + let meas_nodes: Vec = measurements.iter().map(|&m| m.into()).collect(); + let pauli = self.pauli_from_measurement_nodes(&meas_nodes); + let idx = self.annotations.len(); + self.annotations.push(PauliAnnotation { + pauli, + kind: AnnotationKind::Detector { + measurement_nodes: meas_nodes, + coords: Vec::new(), + }, + label: Some(label.to_string()), + }); + idx + } + + /// Annotate a detector with coordinates. + pub fn detector_with_coords( + &mut self, + measurements: &[impl Into + Copy], + coords: &[f64], + ) -> usize { + let meas_nodes: Vec = measurements.iter().map(|&m| m.into()).collect(); + let pauli = self.pauli_from_measurement_nodes(&meas_nodes); + let idx = self.annotations.len(); + self.annotations.push(PauliAnnotation { + pauli, + kind: AnnotationKind::Detector { + measurement_nodes: meas_nodes, + coords: coords.to_vec(), + }, + label: None, + }); + idx + } + + /// Annotate a logical observable: a set of measurements whose XOR + /// defines whether a logical operator flipped. + /// + /// The Pauli string is automatically Z on the measured qubits. + /// + /// Returns the annotation index. + pub fn observable(&mut self, measurements: &[impl Into + Copy]) -> usize { + let meas_nodes: Vec = measurements.iter().map(|&m| m.into()).collect(); + let pauli = self.pauli_from_measurement_nodes(&meas_nodes); + let idx = self.annotations.len(); + self.annotations.push(PauliAnnotation { + pauli, + kind: AnnotationKind::Observable { + measurement_nodes: meas_nodes, + }, + label: None, + }); + idx + } + + /// Annotate a labeled observable. + pub fn observable_labeled( + &mut self, + label: &str, + measurements: &[impl Into + Copy], + ) -> usize { + let meas_nodes: Vec = measurements.iter().map(|&m| m.into()).collect(); + let pauli = self.pauli_from_measurement_nodes(&meas_nodes); + let idx = self.annotations.len(); + self.annotations.push(PauliAnnotation { + pauli, + kind: AnnotationKind::Observable { + measurement_nodes: meas_nodes, + }, + label: Some(label.to_string()), + }); + idx + } + + /// Derive a `PauliString` from measurement nodes. + /// Z-basis measurements → Z on the measured qubit. + fn pauli_from_measurement_nodes(&self, nodes: &[usize]) -> pecos_core::PauliString { + let qubits: Vec = nodes + .iter() + .filter_map(|&node| { + let gate = self.gate(node)?; + Some(gate.qubits.iter().map(pecos_core::QubitId::index)) + }) + .flatten() + .collect(); + pecos_core::PauliString::zs(&qubits) + } + + /// Place a tracked-Pauli meta-gate at this point in the circuit. + /// + /// This is a **positional** annotation: only faults BEFORE this node + /// can flip the tracked Pauli. The meta-gate does not affect quantum state + /// -- simulators ignore it. + /// + /// Accepts a [`PauliString`](pecos_core::PauliString), which supports + /// the `X(q) & Y(q) & Z(q)` composition syntax. + /// + /// Returns the annotation index. + /// + /// # Example + /// ``` + /// use pecos_quantum::DagCircuit; + /// use pecos_core::pauli::{X, Z}; + /// + /// let mut c = DagCircuit::new(); + /// c.pz(&[0, 1, 2]); + /// c.cx(&[(0, 1)]); + /// // Place X_0 & Z_1 & Z_2 check HERE -- only faults above can flip it + /// c.tracked_pauli(X(0) & Z(1) & Z(2)); + /// c.cx(&[(1, 2)]); // faults here don't affect the check + /// ``` + pub fn tracked_pauli(&mut self, mut pauli: pecos_core::PauliString) -> usize { + // Phase is irrelevant for flip tracking -- normalize to +1 + pauli.set_phase(pecos_core::QuarterPhase::PlusOne); + let idx = self.annotations.len(); + self.insert_pauli_meta_gate(&pauli); + self.annotations.push(PauliAnnotation { + pauli, + kind: AnnotationKind::TrackedPauli, + label: None, + }); + idx + } + + /// Place a labeled tracked-Pauli meta-gate. + pub fn tracked_pauli_labeled( + &mut self, + label: &str, + mut pauli: pecos_core::PauliString, + ) -> usize { + pauli.set_phase(pecos_core::QuarterPhase::PlusOne); + let idx = self.annotations.len(); + self.insert_pauli_meta_gate(&pauli); + self.annotations.push(PauliAnnotation { + pauli, + kind: AnnotationKind::TrackedPauli, + label: Some(label.to_string()), + }); + idx + } + + /// Insert a `TrackedPauliMeta` gate node into the DAG. + fn insert_pauli_meta_gate(&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.add_gate_auto_wire(gate); + } + + /// Get all annotations. + #[must_use] + pub fn annotations(&self) -> &[PauliAnnotation] { + &self.annotations + } + + /// Add a pre-built annotation (used for conversion from `TickCircuit`). + pub fn add_annotation(&mut self, ann: PauliAnnotation) { + // For tracked-Pauli annotations, insert the meta-gate node. + if matches!(ann.kind, AnnotationKind::TrackedPauli) { + self.insert_pauli_meta_gate(&ann.pauli); + } + self.annotations.push(ann); + } + + /// Get detector annotations. + pub fn detectors(&self) -> impl Iterator { + self.annotations + .iter() + .filter(|a| matches!(a.kind, AnnotationKind::Detector { .. })) + } + + /// Get observable annotations. + pub fn observables(&self) -> impl Iterator { + self.annotations + .iter() + .filter(|a| matches!(a.kind, AnnotationKind::Observable { .. })) + } + + /// Get tracked-Pauli annotations. + pub fn tracked_paulis(&self) -> impl Iterator { + self.annotations + .iter() + .filter(|a| matches!(a.kind, AnnotationKind::TrackedPauli)) + } + + // ======================================================================== + // Preparation Gates + // ======================================================================== + /// Prepare qubit(s) in the |0> state (Z-basis preparation). pub fn pz(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { for &q in qubits { @@ -1918,6 +2212,44 @@ mod tests { assert_eq!(circuit.two_qubit_gate_count(), 1); } + #[test] + fn test_batched_gate_nodes_count_gates() { + let mut circuit = DagCircuit::new(); + + circuit.add_gate(Gate::h(&[0, 1, 2, 3])); + circuit.add_gate(Gate::cx(&[(0, 1), (2, 3)])); + + assert_eq!(circuit.nodes().len(), 2); + assert_eq!(circuit.gate_node_count(), 2); + assert_eq!(circuit.gate_count(), 6); + assert_eq!(circuit.build_traversal_index().num_gate_nodes(), 2); + assert_eq!(circuit.single_qubit_gate_count(), 4); + assert_eq!(circuit.two_qubit_gate_count(), 2); + assert_eq!(circuit.gate_type_count(GateType::H), 4); + assert_eq!(circuit.gate_type_count(GateType::CX), 2); + } + + #[test] + fn test_separate_compatible_nodes_remain_separate_nodes() { + let mut circuit = DagCircuit::new(); + + circuit.add_gate(Gate::h(&[0])); + circuit.add_gate(Gate::h(&[1])); + circuit.add_gate(Gate::cx(&[(2, 3)])); + circuit.add_gate(Gate::cx(&[(4, 5)])); + + assert_eq!(circuit.gate_node_count(), 4); + assert_eq!(circuit.gate_count(), 4); + assert_eq!(circuit.build_traversal_index().num_gate_nodes(), 4); + assert_eq!(circuit.gate_type_count(GateType::H), 2); + assert_eq!(circuit.gate_type_count(GateType::CX), 2); + + let ticks = crate::TickCircuit::from(&circuit); + assert_eq!(ticks.gate_count(), 4); + assert_eq!(ticks.gate_batch_count(), 2); + assert_eq!(ticks.get_tick(0).unwrap().gate_batch_count(), 2); + } + #[test] fn test_two_qubit_gate_multiple_wires() { let mut circuit = DagCircuit::new(); @@ -2404,10 +2736,9 @@ mod tests { .cx(&[(0, 1)]) .meta("fidelity", Attribute::Float(0.99)); - // Chain meta directly from measurement (mz returns &mut Self) - circuit - .mz(&[0]) - .meta("basis", Attribute::String("Z".to_string())); + // Measurement returns node indices; use last_node for meta + circuit.mz(&[0]); + circuit.meta("basis", Attribute::String("Z".to_string())); assert_eq!(circuit.gate_count(), 3); @@ -2449,22 +2780,21 @@ mod tests { } #[test] - fn test_measure_handle_drops_cleanly() { + fn test_mz_returns_refs() { let mut circuit = DagCircuit::new(); - // MeasureHandle can be ignored (dropped without calling .meta()) circuit.h(&[0]); - circuit.mz(&[0]); + let refs = circuit.mz(&[0]); + assert_eq!(refs.len(), 1); circuit.h(&[1]); // continue building assert_eq!(circuit.gate_count(), 3); } #[test] - fn test_prep_handle_drops_cleanly() { + fn test_pz_chainable() { let mut circuit = DagCircuit::new(); - // PrepHandle can be ignored (dropped without calling .meta()) circuit.pz(&[0]); circuit.h(&[0]); // continue building circuit.mz(&[0]); @@ -2564,4 +2894,22 @@ mod tests { assert_eq!(gate_attrs.get("duration"), Some(&Attribute::Float(50.0))); assert_eq!(gate_attrs.get("fidelity"), Some(&Attribute::Float(0.999))); } + + #[test] + fn test_try_add_gate_rejects_invalid_gate_payload() { + let mut circuit = DagCircuit::new(); + let err = circuit + .try_add_gate(Gate::cx(&[(0, 0)])) + .expect_err("DAG should reject invalid gate payloads"); + + assert!(err.contains("requires distinct qubits")); + assert!(circuit.nodes().is_empty()); + } + + #[test] + #[should_panic(expected = "Invalid gate")] + fn test_add_gate_panics_on_invalid_gate_payload() { + let mut circuit = DagCircuit::new(); + circuit.add_gate(Gate::cx(&[(0, 0)])); + } } diff --git a/crates/pecos-quantum/src/diamond_norm.rs b/crates/pecos-quantum/src/diamond_norm.rs new file mode 100644 index 000000000..fd36aabd8 --- /dev/null +++ b/crates/pecos-quantum/src/diamond_norm.rs @@ -0,0 +1,619 @@ +// 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. + +//! Diamond-norm utilities. +//! +//! General channel diamond norm requires solving a semidefinite program. PECOS +//! does not add an external SDP dependency for that. This module exposes exact +//! dependency-free cases that are mathematically closed-form today, plus the +//! linear-algebra pieces needed by a future PECOS-owned general solver. + +use std::error::Error; +use std::fmt; + +use nalgebra::DMatrix; +use num_complex::Complex64; + +use crate::channel::{PauliChannel, basis_bitmask, pauli_basis_len}; + +const DEFAULT_TOLERANCE: f64 = 1e-12; + +/// Error returned by diamond-norm linear-algebra helpers. +#[derive(Debug, Clone, PartialEq)] +pub enum DiamondNormError { + /// A matrix was not square. + NonSquareMatrix { + /// Row count. + rows: usize, + /// Column count. + cols: usize, + }, + /// A scaled-vector input had the wrong length for the requested matrix. + InvalidSvecLength { + /// Expected triangular-vector length. + expected: usize, + /// Actual length. + actual: usize, + }, + /// A matrix expected to be symmetric or Hermitian was not within tolerance. + NonHermitian { + /// Maximum observed entrywise difference from the adjoint/symmetric + /// counterpart. + max_difference: f64, + /// Allowed tolerance. + tolerance: f64, + }, + /// A Choi matrix does not match the expected input/output dimensions. + InvalidChoiShape { + /// Expected row count. + expected_rows: usize, + /// Expected column count. + expected_cols: usize, + /// Actual row count. + rows: usize, + /// Actual column count. + cols: usize, + }, + /// Input/output dimensions overflowed a `usize` matrix dimension. + DimensionOverflow { + /// Input Hilbert-space dimension. + dim_in: usize, + /// Output Hilbert-space dimension. + dim_out: usize, + }, + /// A matrix entry was not finite. + NonFiniteEntry, + /// Two channel representations act on different Hilbert spaces. + QubitCountMismatch { + /// Left channel qubit count. + left: usize, + /// Right channel qubit count. + right: usize, + }, + /// Failed to enumerate the Pauli basis for a channel. + PauliBasis { + /// Underlying reason. + reason: String, + }, +} + +impl fmt::Display for DiamondNormError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::NonSquareMatrix { rows, cols } => { + write!(f, "matrix must be square, got {rows}x{cols}") + } + Self::InvalidSvecLength { expected, actual } => write!( + f, + "invalid scaled-triangle vector length {actual}; expected {expected}" + ), + Self::NonHermitian { + max_difference, + tolerance, + } => write!( + f, + "matrix is not Hermitian/symmetric within tolerance {tolerance}; max difference {max_difference}" + ), + Self::InvalidChoiShape { + expected_rows, + expected_cols, + rows, + cols, + } => write!( + f, + "invalid Choi matrix shape {rows}x{cols}; expected {expected_rows}x{expected_cols}" + ), + Self::DimensionOverflow { dim_in, dim_out } => write!( + f, + "Choi input/output dimensions overflow usize: dim_in={dim_in}, dim_out={dim_out}" + ), + Self::NonFiniteEntry => write!(f, "matrix contains a non-finite entry"), + Self::QubitCountMismatch { left, right } => write!( + f, + "channels must act on the same number of qubits, got {left} and {right}" + ), + Self::PauliBasis { reason } => { + write!(f, "failed to enumerate Pauli basis: {reason}") + } + } + } +} + +impl Error for DiamondNormError {} + +/// Returns `||left - right||_diamond` for two Pauli channels. +/// +/// For Pauli channels, the diamond norm of the channel difference is exactly +/// the L1 distance between the two Pauli probability vectors. Applying the +/// channel difference to half of a maximally entangled state produces +/// orthogonal Pauli-labelled Bell states, so no SDP is needed. +/// +/// # Errors +/// +/// Returns an error if the channels act on different numbers of qubits or the +/// Pauli basis size overflows. +pub fn pauli_channel_diamond_norm( + left: &PauliChannel, + right: &PauliChannel, +) -> Result { + if left.num_qubits() != right.num_qubits() { + return Err(DiamondNormError::QubitCountMismatch { + left: left.num_qubits(), + right: right.num_qubits(), + }); + } + let num_qubits = left.num_qubits(); + let basis_len = pauli_basis_len(num_qubits).map_err(|err| DiamondNormError::PauliBasis { + reason: err.to_string(), + })?; + let mut total = 0.0; + for basis_index in 0..basis_len { + let pauli = + basis_bitmask(num_qubits, basis_index).map_err(|err| DiamondNormError::PauliBasis { + reason: err.to_string(), + })?; + total += (left.probability(&pauli) - right.probability(&pauli)).abs(); + } + Ok(total) +} + +/// Returns the diamond distance between two Pauli channels. +/// +/// The diamond distance is `0.5 * ||left - right||_diamond`, matching the +/// standard trace-distance normalization. +/// +/// # Errors +/// +/// Returns an error if [`pauli_channel_diamond_norm`] fails. +pub fn pauli_channel_diamond_distance( + left: &PauliChannel, + right: &PauliChannel, +) -> Result { + Ok(0.5 * pauli_channel_diamond_norm(left, right)?) +} + +/// Returns the length of the scaled upper-triangular vector for an `n x n` +/// symmetric matrix. +#[must_use] +pub const fn scaled_psd_triangle_len(n: usize) -> usize { + n * (n + 1) / 2 +} + +/// Converts a real symmetric matrix to scaled upper-triangular +/// vector form. +/// +/// Diagonal entries are stored unchanged. Strict upper-triangular entries are +/// multiplied by `sqrt(2)`, preserving Frobenius inner products under vector +/// dot products. +/// +/// # Errors +/// +/// Returns an error when `matrix` is not square, contains non-finite values, or +/// is not symmetric within the default tolerance. +pub fn svec_real_symmetric(matrix: &DMatrix) -> Result, DiamondNormError> { + svec_real_symmetric_with_tolerance(matrix, DEFAULT_TOLERANCE) +} + +/// Converts a real symmetric matrix to scaled upper-triangular vector form +/// with explicit symmetry tolerance. +/// +/// # Errors +/// +/// Returns an error when `matrix` is not square, contains non-finite values, or +/// is not symmetric within `tolerance`. +pub fn svec_real_symmetric_with_tolerance( + matrix: &DMatrix, + tolerance: f64, +) -> Result, DiamondNormError> { + validate_real_symmetric(matrix, tolerance)?; + let n = matrix.nrows(); + let sqrt2 = 2.0_f64.sqrt(); + let mut out = Vec::with_capacity(scaled_psd_triangle_len(n)); + for col in 0..n { + for row in 0..=col { + let scale = if row == col { 1.0 } else { sqrt2 }; + out.push(matrix[(row, col)] * scale); + } + } + Ok(out) +} + +/// Converts scaled upper-triangular vector form back to a real +/// symmetric matrix. +/// +/// # Errors +/// +/// Returns an error when `data.len()` is not `n * (n + 1) / 2` or a data entry +/// is not finite. +pub fn smat_real_symmetric(n: usize, data: &[f64]) -> Result, DiamondNormError> { + let expected = scaled_psd_triangle_len(n); + if data.len() != expected { + return Err(DiamondNormError::InvalidSvecLength { + expected, + actual: data.len(), + }); + } + let sqrt2 = 2.0_f64.sqrt(); + let mut out = DMatrix::zeros(n, n); + let mut idx = 0; + for col in 0..n { + for row in 0..=col { + let value = data[idx]; + if !value.is_finite() { + return Err(DiamondNormError::NonFiniteEntry); + } + let unscaled = if row == col { value } else { value / sqrt2 }; + out[(row, col)] = unscaled; + out[(col, row)] = unscaled; + idx += 1; + } + } + Ok(out) +} + +/// Embeds a complex Hermitian matrix `A = X + iY` as a real symmetric matrix: +/// +/// ```text +/// [ X -Y ] +/// [ Y X ] +/// ``` +/// +/// This embedding maps complex PSD constraints into real PSD constraints, which +/// is the representation needed by a real-valued PECOS SDP implementation. +/// +/// # Errors +/// +/// Returns an error when `matrix` is not square, contains non-finite values, or +/// is not Hermitian within the default tolerance. +pub fn hermitian_to_real_symmetric( + matrix: &DMatrix, +) -> Result, DiamondNormError> { + hermitian_to_real_symmetric_with_tolerance(matrix, DEFAULT_TOLERANCE) +} + +/// Hermitian-to-real-symmetric embedding with explicit tolerance. +/// +/// # Errors +/// +/// Returns an error when `matrix` is not square, contains non-finite values, or +/// is not Hermitian within `tolerance`. +pub fn hermitian_to_real_symmetric_with_tolerance( + matrix: &DMatrix, + tolerance: f64, +) -> Result, DiamondNormError> { + validate_hermitian(matrix, tolerance)?; + let n = matrix.nrows(); + let mut out = DMatrix::zeros(2 * n, 2 * n); + for row in 0..n { + for col in 0..n { + let value = matrix[(row, col)]; + out[(row, col)] = value.re; + out[(row, col + n)] = -value.im; + out[(row + n, col)] = value.im; + out[(row + n, col + n)] = value.re; + } + } + Ok(out) +} + +/// Converts PECOS's column-stacked Choi convention to the transposed +/// row-vector convention used by the Watrous diamond-norm SDP objective. +/// +/// PECOS indexes Choi rows and columns as `output + input * dim_output`. +/// This helper performs the convention transform used when assembling the +/// row-vector form of the Watrous SDP: +/// +/// ```text +/// reshape(J, (dim_in, dim_out, dim_in, dim_out)) +/// transpose axes (3, 2, 1, 0) +/// reshape back to a matrix +/// ``` +/// +/// The result is not a public diamond-norm implementation. It is a tested +/// convention helper for the future PECOS-owned SDP assembly. +/// +/// # Errors +/// +/// Returns an error if `choi` is not `(dim_in * dim_out) x (dim_in * dim_out)` +/// or if it contains a non-finite entry. +pub fn choi_to_watrous_row_transpose( + choi: &DMatrix, + dim_in: usize, + dim_out: usize, +) -> Result, DiamondNormError> { + let size = dim_in + .checked_mul(dim_out) + .ok_or(DiamondNormError::DimensionOverflow { dim_in, dim_out })?; + if choi.nrows() != size || choi.ncols() != size { + return Err(DiamondNormError::InvalidChoiShape { + expected_rows: size, + expected_cols: size, + rows: choi.nrows(), + cols: choi.ncols(), + }); + } + + let mut out = DMatrix::zeros(size, size); + for input_row in 0..dim_in { + for output_row in 0..dim_out { + let src_row = output_row + input_row * dim_out; + for input_col in 0..dim_in { + for output_col in 0..dim_out { + let src_col = output_col + input_col * dim_out; + let value = choi[(src_row, src_col)]; + if !value.re.is_finite() || !value.im.is_finite() { + return Err(DiamondNormError::NonFiniteEntry); + } + let dst_row = input_col + output_col * dim_in; + let dst_col = input_row + output_row * dim_in; + out[(dst_row, dst_col)] = value; + } + } + } + } + Ok(out) +} + +fn validate_real_symmetric(matrix: &DMatrix, tolerance: f64) -> Result<(), DiamondNormError> { + if matrix.nrows() != matrix.ncols() { + return Err(DiamondNormError::NonSquareMatrix { + rows: matrix.nrows(), + cols: matrix.ncols(), + }); + } + let mut max_difference: f64 = 0.0; + for row in 0..matrix.nrows() { + for col in 0..matrix.ncols() { + let value = matrix[(row, col)]; + if !value.is_finite() { + return Err(DiamondNormError::NonFiniteEntry); + } + max_difference = max_difference.max((value - matrix[(col, row)]).abs()); + } + } + if max_difference > tolerance { + return Err(DiamondNormError::NonHermitian { + max_difference, + tolerance, + }); + } + Ok(()) +} + +fn validate_hermitian(matrix: &DMatrix, tolerance: f64) -> Result<(), DiamondNormError> { + if matrix.nrows() != matrix.ncols() { + return Err(DiamondNormError::NonSquareMatrix { + rows: matrix.nrows(), + cols: matrix.ncols(), + }); + } + let mut max_difference: f64 = 0.0; + for row in 0..matrix.nrows() { + for col in 0..matrix.ncols() { + let value = matrix[(row, col)]; + if !value.re.is_finite() || !value.im.is_finite() { + return Err(DiamondNormError::NonFiniteEntry); + } + max_difference = max_difference.max((value - matrix[(col, row)].conj()).norm()); + } + } + if max_difference > tolerance { + return Err(DiamondNormError::NonHermitian { + max_difference, + tolerance, + }); + } + Ok(()) +} + +#[cfg(test)] +mod tests { + use super::*; + use std::collections::BTreeMap; + + fn assert_close(left: f64, right: f64) { + assert!((left - right).abs() < 1e-12, "{left} != {right}"); + } + + #[test] + fn pauli_channel_diamond_norm_is_l1_probability_distance() { + let left = PauliChannel::one_qubit(0.1, 0.2, 0.0).unwrap(); + let right = PauliChannel::one_qubit(0.0, 0.2, 0.3).unwrap(); + + assert_close(pauli_channel_diamond_norm(&left, &right).unwrap(), 0.6); + assert_close(pauli_channel_diamond_distance(&left, &right).unwrap(), 0.3); + } + + #[test] + fn pauli_channel_diamond_norm_includes_absent_terms_as_zero() { + let mut left_probs = BTreeMap::new(); + left_probs.insert(basis_bitmask(2, 0).unwrap(), 0.9); + left_probs.insert(basis_bitmask(2, 5).unwrap(), 0.1); + let left = PauliChannel::try_new(2, left_probs).unwrap(); + + let mut right_probs = BTreeMap::new(); + right_probs.insert(basis_bitmask(2, 0).unwrap(), 0.8); + right_probs.insert(basis_bitmask(2, 10).unwrap(), 0.2); + let right = PauliChannel::try_new(2, right_probs).unwrap(); + + assert_close(pauli_channel_diamond_norm(&left, &right).unwrap(), 0.4); + } + + #[test] + fn pauli_channel_diamond_norm_handles_sparse_three_qubit_channels() { + let mut left_probs = BTreeMap::new(); + left_probs.insert(basis_bitmask(3, 0).unwrap(), 0.7); + left_probs.insert(basis_bitmask(3, 1).unwrap(), 0.1); + left_probs.insert(basis_bitmask(3, 17).unwrap(), 0.2); + let left = PauliChannel::try_new(3, left_probs).unwrap(); + + let mut right_probs = BTreeMap::new(); + right_probs.insert(basis_bitmask(3, 0).unwrap(), 0.6); + right_probs.insert(basis_bitmask(3, 17).unwrap(), 0.1); + right_probs.insert(basis_bitmask(3, 63).unwrap(), 0.3); + let right = PauliChannel::try_new(3, right_probs).unwrap(); + + assert_close(pauli_channel_diamond_norm(&left, &right).unwrap(), 0.6); + assert_close(pauli_channel_diamond_distance(&left, &right).unwrap(), 0.3); + } + + #[test] + fn pauli_channel_diamond_norm_rejects_qubit_count_mismatch() { + let left = PauliChannel::one_qubit(0.1, 0.0, 0.0).unwrap(); + let mut right_probs = BTreeMap::new(); + right_probs.insert(basis_bitmask(2, 0).unwrap(), 1.0); + let right = PauliChannel::try_new(2, right_probs).unwrap(); + + assert!(matches!( + pauli_channel_diamond_norm(&left, &right).unwrap_err(), + DiamondNormError::QubitCountMismatch { left: 1, right: 2 } + )); + } + + fn frobenius_inner(left: &DMatrix, right: &DMatrix) -> f64 { + left.iter().zip(right.iter()).map(|(a, b)| a * b).sum() + } + + #[test] + fn scaled_triangle_round_trips_real_symmetric_matrix() { + let matrix = + DMatrix::from_row_slice(3, 3, &[1.0, 2.0, -3.0, 2.0, 5.0, 7.0, -3.0, 7.0, 11.0]); + let packed = svec_real_symmetric(&matrix).unwrap(); + assert_eq!(packed.len(), 6); + let recovered = smat_real_symmetric(3, &packed).unwrap(); + for row in 0..3 { + for col in 0..3 { + assert_close(recovered[(row, col)], matrix[(row, col)]); + } + } + } + + #[test] + fn scaled_triangle_preserves_frobenius_inner_product() { + let a = DMatrix::from_row_slice(2, 2, &[1.0, 3.0, 3.0, 2.0]); + let b = DMatrix::from_row_slice(2, 2, &[5.0, -7.0, -7.0, 11.0]); + let a_vec = svec_real_symmetric(&a).unwrap(); + let b_vec = svec_real_symmetric(&b).unwrap(); + let vector_inner: f64 = a_vec.iter().zip(b_vec.iter()).map(|(x, y)| x * y).sum(); + + assert_close(vector_inner, frobenius_inner(&a, &b)); + } + + #[test] + fn hermitian_embedding_is_real_symmetric_and_trace_scaled() { + let i = Complex64::new(0.0, 1.0); + let matrix = DMatrix::from_row_slice( + 2, + 2, + &[Complex64::new(2.0, 0.0), i, -i, Complex64::new(3.0, 0.0)], + ); + let embedded = hermitian_to_real_symmetric(&matrix).unwrap(); + + assert_eq!(embedded.shape(), (4, 4)); + for row in 0..4 { + for col in 0..4 { + assert_close(embedded[(row, col)], embedded[(col, row)]); + } + } + assert_close(embedded.trace(), 10.0); + } + + #[test] + fn choi_to_watrous_row_transpose_matches_reference_axis_permutation() { + let choi = DMatrix::from_row_slice( + 4, + 4, + &[ + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(2.0, 0.0), + Complex64::new(3.0, 0.0), + Complex64::new(4.0, 0.0), + Complex64::new(5.0, 0.0), + Complex64::new(6.0, 0.0), + Complex64::new(7.0, 0.0), + Complex64::new(8.0, 0.0), + Complex64::new(9.0, 0.0), + Complex64::new(10.0, 0.0), + Complex64::new(11.0, 0.0), + Complex64::new(12.0, 0.0), + Complex64::new(13.0, 0.0), + Complex64::new(14.0, 0.0), + Complex64::new(15.0, 0.0), + ], + ); + let converted = choi_to_watrous_row_transpose(&choi, 2, 2).unwrap(); + let expected = DMatrix::from_row_slice( + 4, + 4, + &[ + Complex64::new(0.0, 0.0), + Complex64::new(8.0, 0.0), + Complex64::new(4.0, 0.0), + Complex64::new(12.0, 0.0), + Complex64::new(2.0, 0.0), + Complex64::new(10.0, 0.0), + Complex64::new(6.0, 0.0), + Complex64::new(14.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(9.0, 0.0), + Complex64::new(5.0, 0.0), + Complex64::new(13.0, 0.0), + Complex64::new(3.0, 0.0), + Complex64::new(11.0, 0.0), + Complex64::new(7.0, 0.0), + Complex64::new(15.0, 0.0), + ], + ); + + assert_eq!(converted, expected); + } + + #[test] + fn helper_validation_rejects_invalid_inputs() { + assert!(matches!( + svec_real_symmetric(&DMatrix::zeros(2, 3)).unwrap_err(), + DiamondNormError::NonSquareMatrix { .. } + )); + + let nonsymmetric = DMatrix::from_row_slice(2, 2, &[1.0, 2.0, 3.0, 4.0]); + assert!(matches!( + svec_real_symmetric(&nonsymmetric).unwrap_err(), + DiamondNormError::NonHermitian { .. } + )); + + assert!(matches!( + smat_real_symmetric(3, &[1.0, 2.0]).unwrap_err(), + DiamondNormError::InvalidSvecLength { .. } + )); + + let nonhermitian = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(1.0, 1.0), + Complex64::new(1.0, 1.0), + Complex64::new(1.0, 0.0), + ], + ); + assert!(matches!( + hermitian_to_real_symmetric(&nonhermitian).unwrap_err(), + DiamondNormError::NonHermitian { .. } + )); + + assert!(matches!( + choi_to_watrous_row_transpose(&DMatrix::zeros(2, 2), 2, 2).unwrap_err(), + DiamondNormError::InvalidChoiShape { .. } + )); + } +} diff --git a/crates/pecos-quantum/src/lib.rs b/crates/pecos-quantum/src/lib.rs index 949583ce1..44bf47a1b 100644 --- a/crates/pecos-quantum/src/lib.rs +++ b/crates/pecos-quantum/src/lib.rs @@ -63,16 +63,18 @@ //! circuit2.tick().mz(&[0, 1, 2, 3]); // Measure multiple qubits //! ``` +pub mod channel; mod circuit; mod circuit_display; mod dag_circuit; +pub mod diamond_norm; +pub mod measures; pub mod pass; pub mod pauli_group; pub mod pauli_sequence; pub mod pauli_set; pub mod stabilizer_group; mod tick_circuit; -pub mod tick_circuit_soa; pub mod unitary_matrix; #[cfg(feature = "hugr")] @@ -80,15 +82,12 @@ pub mod hugr_convert; pub use circuit::{Circuit, CircuitMut, GateHandle, GateView}; pub use dag_circuit::{ - Attribute, DagCircuit, DagTraversalIndex, MeasureHandle, PrepHandle, TraversalWorkBuffers, + AnnotationKind, Attribute, DagCircuit, DagTraversalIndex, MeasRef, PauliAnnotation, + TraversalWorkBuffers, }; pub use tick_circuit::{ - CustomGateError, GateSignatureMismatchError, QubitConflictError, Tick, TickCircuit, TickHandle, - TickMeasureHandle, TickPrepHandle, -}; -pub use tick_circuit_soa::{ - CircuitIndexes, GateBatch, GateId, GateStorage, MetadataStorage, TickBatches, TickCircuitSoA, - TickCircuitSoABuilder, TickGateGroups, + CustomGateError, GateSignatureMismatchError, QubitConflictError, Tick, TickCircuit, + TickGateError, TickHandle, TickMeasRef, TickMeasureHandle, TickPrepHandle, }; // Re-export commonly used types from dependencies @@ -96,8 +95,31 @@ pub use pecos_core::gate_type::GateType; pub use pecos_core::{ClassicalBitId, Gate, QubitId, TimeScale, TimeUnits}; pub use pecos_num::dag::DagWouldCycleError; +// Concrete channel representation types +pub use channel::{ + ChannelError, ChiMatrix, ChoiMatrix, DiagonalPtm, KrausOps, MatrixUnitTomographyInput, + PauliChannel, PauliSum, ProcessTomographyDesign, Ptm, PtmBasisOrder, Stinespring, SuperOp, + basis_bitmask, basis_digit_to_pauli, basis_element, basis_index, basis_label, bitmask_label, + matrix_unit_basis, partial_trace, pauli_basis_len, pauli_string_to_bitmask, + pauli_to_basis_digit, random_1q_clifford, random_2q_clifford, random_clifford, + random_density_matrix, random_density_matrix_with_rank, random_pauli, random_quantum_channel, +}; +pub use diamond_norm::{ + DiamondNormError, choi_to_watrous_row_transpose, hermitian_to_real_symmetric, + hermitian_to_real_symmetric_with_tolerance, pauli_channel_diamond_distance, + pauli_channel_diamond_norm, scaled_psd_triangle_len, smat_real_symmetric, svec_real_symmetric, + svec_real_symmetric_with_tolerance, +}; +pub use measures::{ + DensityMatrixPartialTrace, MeasureError, SchmidtTerm, average_gate_fidelity, concurrence, + entanglement_of_formation, entropy, entropy_with_base, gate_error, hellinger_distance, + hellinger_fidelity, logarithmic_negativity, mutual_information, negativity, + partial_trace_qubits, partial_trace_subsystems, process_fidelity, purity, + schmidt_decomposition, shannon_entropy, state_fidelity, state_fidelity_with_density_matrix, +}; + // Re-export operator matrix types for convenient method-style matrix conversion -pub use unitary_matrix::{ToMatrix, UnitaryMatrix}; +pub use unitary_matrix::{ToMatrix, UnitaryMatrix, UnitaryMatrixError, random_unitary}; // Pauli collection and stabilizer group types pub use pauli_group::{PauliGroup, PauliGroupError}; diff --git a/crates/pecos-quantum/src/measures.rs b/crates/pecos-quantum/src/measures.rs new file mode 100644 index 000000000..0943a9ba7 --- /dev/null +++ b/crates/pecos-quantum/src/measures.rs @@ -0,0 +1,1490 @@ +// 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. + +//! Standalone quantum information measures. +//! +//! Measures are free functions so they can be shared across simulator and +//! representation types without forcing every backend into a single state API. + +use std::error::Error; +use std::fmt; + +use nalgebra::{DMatrix, DVector, SVD, Schur}; +use num_complex::Complex64; + +use crate::channel::Ptm; + +const DEFAULT_TOLERANCE: f64 = 1e-12; + +/// One term in a Schmidt decomposition. +/// +/// The tuple is `(coefficient, left_vector, right_vector)`. +pub type SchmidtTerm = (f64, Vec, Vec); + +/// Error returned by quantum-information measure functions. +#[derive(Debug, Clone, PartialEq)] +pub enum MeasureError { + /// The requested Hilbert-space dimension would overflow `usize`. + DimensionOverflow { + /// Number of qubits supplied by the caller. + num_qubits: usize, + }, + /// Two vectors have incompatible lengths. + VectorLengthMismatch { + /// Left vector length. + left: usize, + /// Right vector length. + right: usize, + }, + /// A matrix is not square. + NonSquareMatrix { + /// Actual row count. + rows: usize, + /// Actual column count. + cols: usize, + }, + /// A matrix does not have the expected shape. + InvalidMatrixShape { + /// Expected row count. + expected_rows: usize, + /// Expected column count. + expected_cols: usize, + /// Actual row count. + rows: usize, + /// Actual column count. + cols: usize, + }, + /// Two channel/process representations have incompatible qubit counts. + QubitCountMismatch { + /// Expected qubit count. + expected: usize, + /// Actual qubit count. + actual: usize, + }, + /// A value is not finite. + NonFiniteValue { + /// Offending value. + value: Complex64, + }, + /// A finite complex value was expected to be real within tolerance. + NonRealValue { + /// Offending value. + value: Complex64, + /// Allowed imaginary-part tolerance. + tolerance: f64, + }, + /// A state vector is not normalized. + InvalidStateNorm { + /// Observed squared norm. + norm_sqr: f64, + /// Allowed absolute tolerance. + tolerance: f64, + }, + /// A density matrix is not Hermitian within tolerance. + NonHermitianMatrix { + /// Row index where the mismatch was observed. + row: usize, + /// Column index where the mismatch was observed. + col: usize, + /// Observed entry. + value: Complex64, + /// Conjugate-transposed entry. + adjoint_value: Complex64, + /// Allowed absolute tolerance. + tolerance: f64, + }, + /// A density matrix does not have trace one. + InvalidDensityTrace { + /// Observed trace. + trace: Complex64, + /// Allowed absolute tolerance. + tolerance: f64, + }, + /// The requested logarithm base is invalid for entropy. + InvalidEntropyBase { + /// Invalid base. + base: f64, + }, + /// A probability is negative or non-finite. + InvalidProbability { + /// Index of the invalid probability. + index: usize, + /// Invalid probability value. + probability: f64, + }, + /// A probability distribution does not sum to one. + InvalidProbabilitySum { + /// Observed probability sum. + sum: f64, + /// Allowed absolute tolerance. + tolerance: f64, + }, + /// Subsystem dimensions are invalid for a multipartite measure. + InvalidSubsystemDimensions { + /// Subsystem dimensions supplied by the caller. + dims: Vec, + /// Actual Hilbert-space dimension of the density matrix. + matrix_dim: usize, + }, + /// A subsystem index is outside the supplied tensor-factor list. + SubsystemOutOfRange { + /// Number of subsystems supplied by the caller. + num_subsystems: usize, + /// Invalid subsystem index. + subsystem: usize, + }, + /// A subsystem was listed more than once. + DuplicateSubsystem { + /// Repeated subsystem index. + subsystem: usize, + }, + /// An eigendecomposition did not converge. + EigenDecompositionFailed, +} + +impl fmt::Display for MeasureError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DimensionOverflow { num_qubits } => { + write!( + f, + "Hilbert-space dimension overflows usize for {num_qubits} qubits" + ) + } + Self::VectorLengthMismatch { left, right } => { + write!(f, "vector length mismatch: {left} != {right}") + } + Self::NonSquareMatrix { rows, cols } => { + write!(f, "matrix must be square, got {rows}x{cols}") + } + Self::InvalidMatrixShape { + expected_rows, + expected_cols, + rows, + cols, + } => write!( + f, + "invalid matrix shape {rows}x{cols}; expected {expected_rows}x{expected_cols}" + ), + Self::QubitCountMismatch { expected, actual } => { + write!(f, "qubit count mismatch: expected {expected}, got {actual}") + } + Self::NonFiniteValue { value } => write!(f, "non-finite value: {value}"), + Self::NonRealValue { value, tolerance } => write!( + f, + "value must be real within tolerance {tolerance}, got {value}" + ), + Self::InvalidStateNorm { + norm_sqr, + tolerance, + } => write!( + f, + "state vector squared norm must be 1 within tolerance {tolerance}, got {norm_sqr}" + ), + Self::NonHermitianMatrix { + row, + col, + value, + adjoint_value, + tolerance, + } => write!( + f, + "matrix is not Hermitian within tolerance {tolerance} at ({row}, {col}): {value} != {adjoint_value}" + ), + Self::InvalidDensityTrace { trace, tolerance } => write!( + f, + "density matrix trace must be 1 within tolerance {tolerance}, got {trace}" + ), + Self::InvalidEntropyBase { base } => { + write!( + f, + "entropy logarithm base must be finite, positive, and not 1; got {base}" + ) + } + Self::InvalidProbability { index, probability } => write!( + f, + "probability at index {index} must be finite and non-negative, got {probability}" + ), + Self::InvalidProbabilitySum { sum, tolerance } => write!( + f, + "probability distribution must sum to 1 within tolerance {tolerance}, got {sum}" + ), + Self::InvalidSubsystemDimensions { dims, matrix_dim } => write!( + f, + "invalid subsystem dimensions {dims:?} for density matrix dimension {matrix_dim}" + ), + Self::SubsystemOutOfRange { + num_subsystems, + subsystem, + } => write!( + f, + "subsystem {subsystem} is outside the {num_subsystems}-subsystem tensor product" + ), + Self::DuplicateSubsystem { subsystem } => { + write!(f, "duplicate subsystem index: {subsystem}") + } + Self::EigenDecompositionFailed => write!(f, "eigendecomposition failed"), + } + } +} + +impl Error for MeasureError {} + +/// Method-style partial trace operations for state density matrices. +/// +/// This trait is implemented for `DMatrix` because PECOS currently +/// represents state density matrices as dense complex matrices. The methods +/// validate that the matrix is a trace-one Hermitian density matrix before +/// reducing it. +pub trait DensityMatrixPartialTrace { + /// Returns the reduced density matrix after tracing out selected + /// tensor-product subsystems. + /// + /// `dims[i]` is the Hilbert-space dimension of subsystem `i`. Subsystem 0 + /// is the fastest-varying factor in the computational-basis index. + /// + /// # Errors + /// + /// Returns an error when the matrix is not a structurally valid density + /// matrix, when `dims` do not match its shape, or when `traced_subsystems` + /// contains an out-of-range or repeated subsystem. + fn partial_trace( + &self, + dims: &[usize], + traced_subsystems: &[usize], + ) -> Result, MeasureError>; + + /// Returns the reduced density matrix after tracing out selected qubits. + /// + /// Qubit indexing is little-endian: qubit 0 is the least-significant bit + /// of the computational-basis index. + /// + /// # Errors + /// + /// Returns an error when the matrix is not a structurally valid density + /// matrix, when its shape is not `2^num_qubits x 2^num_qubits`, or when + /// `traced_qubits` contains an out-of-range or repeated qubit. + fn partial_trace_qubits( + &self, + num_qubits: usize, + traced_qubits: &[usize], + ) -> Result, MeasureError>; +} + +impl DensityMatrixPartialTrace for DMatrix { + fn partial_trace( + &self, + dims: &[usize], + traced_subsystems: &[usize], + ) -> Result, MeasureError> { + partial_trace_subsystems(self, dims, traced_subsystems) + } + + fn partial_trace_qubits( + &self, + num_qubits: usize, + traced_qubits: &[usize], + ) -> Result, MeasureError> { + partial_trace_qubits(self, num_qubits, traced_qubits) + } +} + +/// Returns pure-state fidelity `||^2`. +/// +/// Both state vectors must have the same length and be normalized. +/// +/// # Errors +/// +/// Returns an error when lengths differ, entries are non-finite, or either +/// vector is not normalized within tolerance. +pub fn state_fidelity( + left: &DVector, + right: &DVector, +) -> Result { + if left.len() != right.len() { + return Err(MeasureError::VectorLengthMismatch { + left: left.len(), + right: right.len(), + }); + } + validate_state_vector(left)?; + validate_state_vector(right)?; + let overlap: Complex64 = left + .iter() + .zip(right.iter()) + .map(|(left, right)| left.conj() * right) + .sum(); + Ok(overlap.norm_sqr()) +} + +/// Returns fidelity `` between a density matrix and a pure state. +/// +/// `rho` must be a trace-one Hermitian density matrix and `psi` must be a +/// normalized state vector with matching dimension. Positive-semidefinite +/// validation is intentionally not part of this cheap structural check. +/// +/// # Errors +/// +/// Returns an error when dimensions differ or either input is structurally +/// invalid. +pub fn state_fidelity_with_density_matrix( + rho: &DMatrix, + psi: &DVector, +) -> Result { + validate_density_matrix(rho)?; + validate_state_vector(psi)?; + if rho.nrows() != psi.len() { + return Err(MeasureError::InvalidMatrixShape { + expected_rows: psi.len(), + expected_cols: psi.len(), + rows: rho.nrows(), + cols: rho.ncols(), + }); + } + let evolved = rho * psi; + let value: Complex64 = psi + .iter() + .zip(evolved.iter()) + .map(|(left, right)| left.conj() * right) + .sum(); + if value.im.abs() > DEFAULT_TOLERANCE { + return Err(MeasureError::NonRealValue { + value, + tolerance: DEFAULT_TOLERANCE, + }); + } + Ok(value.re) +} + +/// Returns density-matrix purity `Tr(rho^2)`. +/// +/// # Errors +/// +/// Returns an error when `rho` is not square, finite, Hermitian, and trace one. +pub fn purity(rho: &DMatrix) -> Result { + validate_density_matrix(rho)?; + let value = trace(&(rho * rho)); + if value.im.abs() > DEFAULT_TOLERANCE { + return Err(MeasureError::NonRealValue { + value, + tolerance: DEFAULT_TOLERANCE, + }); + } + Ok(value.re) +} + +/// Returns the von Neumann entropy `-Tr(rho log_2 rho)`. +/// +/// `rho` must be a positive-semidefinite density matrix. This function +/// validates the cheap structural conditions (square, finite, Hermitian, +/// trace one) and computes the entropy from singular values, which equal the +/// eigenvalues for valid density matrices. +/// +/// # Errors +/// +/// Returns an error when `rho` is structurally invalid. +pub fn entropy(rho: &DMatrix) -> Result { + entropy_with_base(rho, 2.0) +} + +/// Returns the von Neumann entropy `-Tr(rho log_base rho)`. +/// +/// # Errors +/// +/// Returns an error when `rho` is structurally invalid or `base` is not finite, +/// positive, and different from one. +pub fn entropy_with_base(rho: &DMatrix, base: f64) -> Result { + validate_density_matrix(rho)?; + validate_entropy_base(base)?; + let svd = SVD::new(rho.clone(), false, false); + shannon_entropy(svd.singular_values.as_slice(), base) +} + +/// Returns the Shannon entropy of a probability distribution. +/// +/// The result is `-sum_i p_i log_base(p_i)`. Zero-probability entries +/// contribute zero. +/// +/// # Errors +/// +/// Returns an error when `base` is invalid, when a probability is negative or +/// non-finite, or when the probabilities do not sum to one. +/// +/// # Examples +/// +/// ``` +/// use pecos_quantum::shannon_entropy; +/// +/// let entropy = shannon_entropy(&[0.5, 0.5], 2.0).unwrap(); +/// assert!((entropy - 1.0).abs() < 1e-12); +/// ``` +pub fn shannon_entropy(probabilities: &[f64], base: f64) -> Result { + validate_entropy_base(base)?; + validate_probability_distribution(probabilities)?; + let log_base = base.ln(); + Ok(probabilities + .iter() + .copied() + .filter(|probability| *probability > DEFAULT_TOLERANCE) + .map(|probability| -probability * probability.ln() / log_base) + .sum()) +} + +/// Returns normalized process fidelity between two PTMs. +/// +/// With PECOS's normalized Pauli basis convention, this is +/// `Tr(R_left^T R_right) / 4^n`. Identity compared with identity gives 1. +/// +/// # Errors +/// +/// Returns an error when the PTMs have different qubit counts. +pub fn process_fidelity(left: &Ptm, right: &Ptm) -> Result { + if left.num_qubits() != right.num_qubits() { + return Err(MeasureError::QubitCountMismatch { + expected: left.num_qubits(), + actual: right.num_qubits(), + }); + } + #[allow(clippy::cast_precision_loss)] + let basis_len = left.matrix().nrows() as f64; + let value: f64 = left + .matrix() + .iter() + .zip(right.matrix().iter()) + .map(|(left, right)| left * right) + .sum::() + / basis_len; + Ok(value) +} + +/// Returns average gate fidelity between two PTMs. +/// +/// This uses `F_avg = (d F_process + 1) / (d + 1)` for Hilbert-space +/// dimension `d = 2^n`. +/// +/// # Errors +/// +/// Returns an error when the PTMs have different qubit counts or the Hilbert +/// dimension overflows. +pub fn average_gate_fidelity(left: &Ptm, right: &Ptm) -> Result { + let process = process_fidelity(left, right)?; + let dim = hilbert_dim(left.num_qubits())?; + #[allow(clippy::cast_precision_loss)] + let dim = dim as f64; + Ok((dim * process + 1.0) / (dim + 1.0)) +} + +/// Returns average gate error `1 - average_gate_fidelity`. +/// +/// # Errors +/// +/// Returns an error when [`average_gate_fidelity`] fails. +pub fn gate_error(left: &Ptm, right: &Ptm) -> Result { + Ok(1.0 - average_gate_fidelity(left, right)?) +} + +/// Returns the two-qubit concurrence of a density matrix. +/// +/// For a two-qubit state `rho`, this computes Wootters' concurrence using the +/// spin-flipped matrix `rho_tilde = (Y ⊗ Y) rho* (Y ⊗ Y)` and the square roots +/// of the eigenvalues of `rho rho_tilde`. +/// +/// # Errors +/// +/// Returns an error when `rho` is not a structurally valid 4x4 density matrix, +/// or when the eigendecomposition fails. +pub fn concurrence(rho: &DMatrix) -> Result { + validate_density_matrix(rho)?; + if rho.nrows() != 4 || rho.ncols() != 4 { + return Err(MeasureError::InvalidMatrixShape { + expected_rows: 4, + expected_cols: 4, + rows: rho.nrows(), + cols: rho.ncols(), + }); + } + + let yy = pauli_y_tensor_pauli_y(); + let rho_conj = rho.map(|value| value.conj()); + let rho_tilde = &yy * rho_conj * yy; + let product = rho * rho_tilde; + let eigenvalues = Schur::try_new(product, DEFAULT_TOLERANCE, 0) + .and_then(|schur| schur.eigenvalues()) + .ok_or(MeasureError::EigenDecompositionFailed)?; + + let mut roots: Vec = eigenvalues + .iter() + .map(|lambda| { + if lambda.im.abs() <= 1e-8 { + lambda.re.max(0.0).sqrt() + } else { + lambda.norm().sqrt() + } + }) + .collect(); + roots.sort_by(|a, b| b.total_cmp(a)); + let value = roots[0] - roots[1] - roots[2] - roots[3]; + Ok(value.clamp(0.0, 1.0)) +} + +/// Returns the two-qubit entanglement of formation. +/// +/// This is derived from [`concurrence`] as +/// `h((1 + sqrt(1 - C^2)) / 2)`, where `h` is binary entropy. +/// +/// # Errors +/// +/// Returns an error when [`concurrence`] fails. +pub fn entanglement_of_formation(rho: &DMatrix) -> Result { + let concurrence = concurrence(rho)?; + let argument = f64::midpoint(1.0, (1.0 - concurrence * concurrence).max(0.0).sqrt()); + Ok(binary_entropy(argument)) +} + +/// Returns the entanglement negativity of a bipartite or multipartite state. +/// +/// This computes `(||rho^T_s||_1 - 1) / 2`, where `rho^T_s` is the partial +/// transpose with respect to `subsystem`. +/// +/// # Errors +/// +/// Returns an error when `rho` is not a structurally valid density matrix, +/// when `dims` do not match its Hilbert-space dimension, or when `subsystem` +/// is out of range. +pub fn negativity( + rho: &DMatrix, + dims: &[usize], + subsystem: usize, +) -> Result { + let partial_transpose = partial_transpose_subsystem(rho, dims, subsystem)?; + let trace_norm: f64 = SVD::new(partial_transpose, false, false) + .singular_values + .iter() + .sum(); + Ok(((trace_norm - 1.0) / 2.0).max(0.0)) +} + +/// Returns logarithmic negativity `log2(2 * negativity + 1)`. +/// +/// # Errors +/// +/// Returns an error when [`negativity`] fails. +pub fn logarithmic_negativity( + rho: &DMatrix, + dims: &[usize], + subsystem: usize, +) -> Result { + Ok((2.0 * negativity(rho, dims, subsystem)? + 1.0).log2()) +} + +/// Returns the Schmidt decomposition of a pure state across a bipartition. +/// +/// `dims[i]` is the dimension of subsystem `i`; subsystem 0 is the +/// fastest-varying factor. `left_subsystems` selects the left side of the +/// bipartition. The right side is the sorted complement. Returned terms are +/// `(coefficient, left_vector, right_vector)` and omit numerically-zero +/// coefficients. +/// +/// # Errors +/// +/// Returns an error when the state is not normalized, when `dims` do not match +/// the state-vector length, or when `left_subsystems` contains an invalid or +/// repeated subsystem. +pub fn schmidt_decomposition( + state: &DVector, + dims: &[usize], + left_subsystems: &[usize], +) -> Result, MeasureError> { + validate_state_vector(state)?; + validate_state_subsystem_dimensions(state.len(), dims)?; + let left = validated_sorted_subsystems(dims, left_subsystems)?; + let right: Vec = (0..dims.len()) + .filter(|subsystem| left.binary_search(subsystem).is_err()) + .collect(); + let left_dim = subsystem_product(dims, &left)?; + let right_dim = subsystem_product(dims, &right)?; + let strides = subsystem_strides(dims)?; + + let mut matrix = DMatrix::zeros(left_dim, right_dim); + for basis_index in 0..state.len() { + let left_index = project_subsystem_index(dims, &strides, &left, basis_index); + let right_index = project_subsystem_index(dims, &strides, &right, basis_index); + matrix[(left_index, right_index)] = state[basis_index]; + } + + let svd = SVD::new(matrix, true, true); + let left_vectors = svd.u.ok_or(MeasureError::EigenDecompositionFailed)?; + let right_vectors_adjoint = svd.v_t.ok_or(MeasureError::EigenDecompositionFailed)?; + + Ok(svd + .singular_values + .iter() + .enumerate() + .filter(|(_, coefficient)| **coefficient > DEFAULT_TOLERANCE) + .map(|(idx, &coefficient)| { + let left_vector = left_vectors.column(idx).iter().copied().collect(); + let right_vector = right_vectors_adjoint + .row(idx) + .iter() + .copied() + .map(|value| value.conj()) + .collect(); + (coefficient, left_vector, right_vector) + }) + .collect()) +} + +/// Returns the reduced density matrix after tracing out selected subsystems. +/// +/// `dims[i]` is the Hilbert-space dimension of subsystem `i`. Subsystem 0 is +/// the fastest-varying factor in the computational-basis index, matching PECOS +/// little-endian qubit ordering. The returned density matrix keeps untraced +/// subsystems in ascending subsystem-index order. +/// +/// # Errors +/// +/// Returns an error when `rho` is not a structurally valid density matrix, +/// when the product of `dims` does not match `rho`, or when +/// `traced_subsystems` contains an out-of-range or repeated subsystem. +pub fn partial_trace_subsystems( + rho: &DMatrix, + dims: &[usize], + traced_subsystems: &[usize], +) -> Result, MeasureError> { + validate_density_matrix(rho)?; + validate_subsystem_dimensions(rho, dims)?; + + let mut traced = traced_subsystems.to_vec(); + traced.sort_unstable(); + for window in traced.windows(2) { + if window[0] == window[1] { + return Err(MeasureError::DuplicateSubsystem { + subsystem: window[0], + }); + } + } + for &subsystem in &traced { + if subsystem >= dims.len() { + return Err(MeasureError::SubsystemOutOfRange { + num_subsystems: dims.len(), + subsystem, + }); + } + } + + let kept: Vec = (0..dims.len()) + .filter(|subsystem| traced.binary_search(subsystem).is_err()) + .collect(); + let out_dim = subsystem_product(dims, &kept)?; + let traced_dim = subsystem_product(dims, &traced)?; + let strides = subsystem_strides(dims)?; + + let mut out = DMatrix::zeros(out_dim, out_dim); + for kept_row in 0..out_dim { + for kept_col in 0..out_dim { + let mut value = Complex64::new(0.0, 0.0); + for traced_idx in 0..traced_dim { + let row = + embed_subsystem_index(dims, &strides, &kept, kept_row, &traced, traced_idx); + let col = + embed_subsystem_index(dims, &strides, &kept, kept_col, &traced, traced_idx); + value += rho[(row, col)]; + } + out[(kept_row, kept_col)] = value; + } + } + Ok(out) +} + +/// Returns the reduced density matrix after tracing out selected qubits. +/// +/// Qubit indexing is little-endian: qubit 0 is the least-significant bit of +/// the computational-basis index. The returned density matrix keeps untraced +/// qubits in ascending qubit-index order. +/// +/// # Errors +/// +/// Returns an error when `rho` is not a structurally valid density matrix, when +/// its shape is not `2^num_qubits x 2^num_qubits`, or when `traced_qubits` +/// contains an out-of-range or repeated qubit. +pub fn partial_trace_qubits( + rho: &DMatrix, + num_qubits: usize, + traced_qubits: &[usize], +) -> Result, MeasureError> { + let dims = vec![2; num_qubits]; + partial_trace_subsystems(rho, &dims, traced_qubits) +} + +/// Returns bipartite quantum mutual information. +/// +/// `dims` is `(dim_a, dim_b)`, and `rho` must have shape +/// `(dim_a * dim_b) x (dim_a * dim_b)`. Subsystem `A` is the fastest-varying +/// factor in the computational-basis index, matching +/// [`partial_trace_subsystems`]. +/// +/// # Errors +/// +/// Returns an error when `rho` is not a structurally valid density matrix, when +/// `dims` are invalid, or when entropy evaluation fails on a reduced state. +pub fn mutual_information( + rho: &DMatrix, + dims: (usize, usize), +) -> Result { + validate_density_matrix(rho)?; + let (dim_a, dim_b) = dims; + let Some(total_dim) = dim_a.checked_mul(dim_b) else { + return Err(MeasureError::InvalidSubsystemDimensions { + dims: vec![dim_a, dim_b], + matrix_dim: rho.nrows(), + }); + }; + if dim_a == 0 || dim_b == 0 || rho.nrows() != total_dim || rho.ncols() != total_dim { + return Err(MeasureError::InvalidSubsystemDimensions { + dims: vec![dim_a, dim_b], + matrix_dim: rho.nrows(), + }); + } + + let rho_a = partial_trace_subsystems(rho, &[dim_a, dim_b], &[1])?; + let rho_b = partial_trace_subsystems(rho, &[dim_a, dim_b], &[0])?; + Ok(entropy(&rho_a)? + entropy(&rho_b)? - entropy(rho)?) +} + +/// Returns the Hellinger distance between classical probability distributions. +/// +/// `H(p, q) = sqrt(1 - sum_i sqrt(p_i q_i))`. +/// +/// # Errors +/// +/// Returns an error when the vectors have different lengths or either vector is +/// not a probability distribution. +pub fn hellinger_distance(left: &[f64], right: &[f64]) -> Result { + if left.len() != right.len() { + return Err(MeasureError::VectorLengthMismatch { + left: left.len(), + right: right.len(), + }); + } + validate_probability_distribution(left)?; + validate_probability_distribution(right)?; + let affinity: f64 = left + .iter() + .zip(right.iter()) + .map(|(&left, &right)| (left * right).sqrt()) + .sum(); + Ok((1.0 - affinity.clamp(0.0, 1.0)).sqrt()) +} + +/// Returns Hellinger fidelity between classical probability distributions. +/// +/// The value is `(1 - H(p, q)^2)^2`, where `H` is +/// [`hellinger_distance`]. +/// +/// # Errors +/// +/// Returns an error when [`hellinger_distance`] fails. +pub fn hellinger_fidelity(left: &[f64], right: &[f64]) -> Result { + let distance = hellinger_distance(left, right)?; + Ok((1.0 - distance * distance).powi(2)) +} + +fn validate_state_vector(vector: &DVector) -> Result<(), MeasureError> { + let mut norm_sqr = 0.0; + for value in vector.iter() { + validate_complex(*value)?; + norm_sqr += value.norm_sqr(); + } + if (norm_sqr - 1.0).abs() > DEFAULT_TOLERANCE { + return Err(MeasureError::InvalidStateNorm { + norm_sqr, + tolerance: DEFAULT_TOLERANCE, + }); + } + Ok(()) +} + +fn validate_density_matrix(matrix: &DMatrix) -> Result<(), MeasureError> { + if matrix.nrows() != matrix.ncols() { + return Err(MeasureError::NonSquareMatrix { + rows: matrix.nrows(), + cols: matrix.ncols(), + }); + } + for value in matrix.iter() { + validate_complex(*value)?; + } + for row in 0..matrix.nrows() { + for col in 0..matrix.ncols() { + let value = matrix[(row, col)]; + let adjoint_value = matrix[(col, row)].conj(); + if (value - adjoint_value).norm() > DEFAULT_TOLERANCE { + return Err(MeasureError::NonHermitianMatrix { + row, + col, + value, + adjoint_value, + tolerance: DEFAULT_TOLERANCE, + }); + } + } + } + let trace = trace(matrix); + if trace.im.abs() > DEFAULT_TOLERANCE || (trace.re - 1.0).abs() > DEFAULT_TOLERANCE { + return Err(MeasureError::InvalidDensityTrace { + trace, + tolerance: DEFAULT_TOLERANCE, + }); + } + Ok(()) +} + +fn validate_complex(value: Complex64) -> Result<(), MeasureError> { + if value.re.is_finite() && value.im.is_finite() { + Ok(()) + } else { + Err(MeasureError::NonFiniteValue { value }) + } +} + +fn validate_entropy_base(base: f64) -> Result<(), MeasureError> { + if base.is_finite() && base > 0.0 && (base - 1.0).abs() > DEFAULT_TOLERANCE { + Ok(()) + } else { + Err(MeasureError::InvalidEntropyBase { base }) + } +} + +fn validate_probability_distribution(probabilities: &[f64]) -> Result<(), MeasureError> { + let mut sum = 0.0; + for (index, &probability) in probabilities.iter().enumerate() { + if !probability.is_finite() || probability < -DEFAULT_TOLERANCE { + return Err(MeasureError::InvalidProbability { index, probability }); + } + sum += probability.max(0.0); + } + if (sum - 1.0).abs() > DEFAULT_TOLERANCE { + return Err(MeasureError::InvalidProbabilitySum { + sum, + tolerance: DEFAULT_TOLERANCE, + }); + } + Ok(()) +} + +fn trace(matrix: &DMatrix) -> Complex64 { + let n = matrix.nrows().min(matrix.ncols()); + (0..n).map(|idx| matrix[(idx, idx)]).sum() +} + +fn pauli_y_tensor_pauli_y() -> DMatrix { + let i = Complex64::new(0.0, 1.0); + let minus_i = Complex64::new(0.0, -1.0); + let y = DMatrix::from_row_slice( + 2, + 2, + &[ + Complex64::new(0.0, 0.0), + minus_i, + i, + Complex64::new(0.0, 0.0), + ], + ); + kronecker(&y, &y) +} + +fn kronecker(left: &DMatrix, right: &DMatrix) -> DMatrix { + let rows = left.nrows() * right.nrows(); + let cols = left.ncols() * right.ncols(); + let mut out = DMatrix::zeros(rows, cols); + for left_row in 0..left.nrows() { + for left_col in 0..left.ncols() { + let scale = left[(left_row, left_col)]; + for right_row in 0..right.nrows() { + for right_col in 0..right.ncols() { + out[( + left_row * right.nrows() + right_row, + left_col * right.ncols() + right_col, + )] = scale * right[(right_row, right_col)]; + } + } + } + } + out +} + +fn validate_subsystem_dimensions( + rho: &DMatrix, + dims: &[usize], +) -> Result<(), MeasureError> { + let Some(total_dim) = + dims.iter().try_fold( + 1usize, + |acc, &dim| { + if dim == 0 { None } else { acc.checked_mul(dim) } + }, + ) + else { + return Err(MeasureError::InvalidSubsystemDimensions { + dims: dims.to_vec(), + matrix_dim: rho.nrows(), + }); + }; + + if rho.nrows() == total_dim && rho.ncols() == total_dim { + Ok(()) + } else { + Err(MeasureError::InvalidSubsystemDimensions { + dims: dims.to_vec(), + matrix_dim: rho.nrows(), + }) + } +} + +fn validate_state_subsystem_dimensions( + state_len: usize, + dims: &[usize], +) -> Result<(), MeasureError> { + let Some(total_dim) = + dims.iter().try_fold( + 1usize, + |acc, &dim| { + if dim == 0 { None } else { acc.checked_mul(dim) } + }, + ) + else { + return Err(MeasureError::InvalidSubsystemDimensions { + dims: dims.to_vec(), + matrix_dim: state_len, + }); + }; + + if state_len == total_dim { + Ok(()) + } else { + Err(MeasureError::InvalidSubsystemDimensions { + dims: dims.to_vec(), + matrix_dim: state_len, + }) + } +} + +fn validated_sorted_subsystems( + dims: &[usize], + subsystems: &[usize], +) -> Result, MeasureError> { + let mut sorted = subsystems.to_vec(); + sorted.sort_unstable(); + for window in sorted.windows(2) { + if window[0] == window[1] { + return Err(MeasureError::DuplicateSubsystem { + subsystem: window[0], + }); + } + } + for &subsystem in &sorted { + if subsystem >= dims.len() { + return Err(MeasureError::SubsystemOutOfRange { + num_subsystems: dims.len(), + subsystem, + }); + } + } + Ok(sorted) +} + +fn subsystem_product(dims: &[usize], subsystems: &[usize]) -> Result { + subsystems.iter().try_fold(1usize, |acc, &subsystem| { + acc.checked_mul(dims[subsystem]) + .ok_or_else(|| MeasureError::InvalidSubsystemDimensions { + dims: dims.to_vec(), + matrix_dim: usize::MAX, + }) + }) +} + +fn subsystem_strides(dims: &[usize]) -> Result, MeasureError> { + let mut strides = Vec::with_capacity(dims.len()); + let mut stride = 1usize; + for &dim in dims { + strides.push(stride); + stride = + stride + .checked_mul(dim) + .ok_or_else(|| MeasureError::InvalidSubsystemDimensions { + dims: dims.to_vec(), + matrix_dim: usize::MAX, + })?; + } + Ok(strides) +} + +fn embed_subsystem_index( + dims: &[usize], + strides: &[usize], + kept_subsystems: &[usize], + kept_index: usize, + traced_subsystems: &[usize], + traced_index: usize, +) -> usize { + let mut index = 0usize; + let mut kept_remaining = kept_index; + for &subsystem in kept_subsystems { + let coord = kept_remaining % dims[subsystem]; + kept_remaining /= dims[subsystem]; + index += coord * strides[subsystem]; + } + let mut traced_remaining = traced_index; + for &subsystem in traced_subsystems { + let coord = traced_remaining % dims[subsystem]; + traced_remaining /= dims[subsystem]; + index += coord * strides[subsystem]; + } + index +} + +fn project_subsystem_index( + dims: &[usize], + strides: &[usize], + subsystems: &[usize], + basis_index: usize, +) -> usize { + let mut out = 0usize; + let mut out_stride = 1usize; + for &subsystem in subsystems { + let coord = (basis_index / strides[subsystem]) % dims[subsystem]; + out += coord * out_stride; + out_stride *= dims[subsystem]; + } + out +} + +fn partial_transpose_subsystem( + rho: &DMatrix, + dims: &[usize], + subsystem: usize, +) -> Result, MeasureError> { + validate_density_matrix(rho)?; + validate_subsystem_dimensions(rho, dims)?; + if subsystem >= dims.len() { + return Err(MeasureError::SubsystemOutOfRange { + num_subsystems: dims.len(), + subsystem, + }); + } + + let strides = subsystem_strides(dims)?; + let mut out = DMatrix::zeros(rho.nrows(), rho.ncols()); + for row in 0..rho.nrows() { + for col in 0..rho.ncols() { + let row_coord = (row / strides[subsystem]) % dims[subsystem]; + let col_coord = (col / strides[subsystem]) % dims[subsystem]; + let transposed_row = + row - row_coord * strides[subsystem] + col_coord * strides[subsystem]; + let transposed_col = + col - col_coord * strides[subsystem] + row_coord * strides[subsystem]; + out[(transposed_row, transposed_col)] = rho[(row, col)]; + } + } + Ok(out) +} + +fn binary_entropy(probability: f64) -> f64 { + let p = probability.clamp(0.0, 1.0); + if p <= DEFAULT_TOLERANCE || (1.0 - p) <= DEFAULT_TOLERANCE { + 0.0 + } else { + -p * p.log2() - (1.0 - p) * (1.0 - p).log2() + } +} + +fn hilbert_dim(num_qubits: usize) -> Result { + 2usize + .checked_pow( + num_qubits + .try_into() + .map_err(|_| MeasureError::DimensionOverflow { num_qubits })?, + ) + .ok_or(MeasureError::DimensionOverflow { num_qubits }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::channel::Ptm; + use pecos_core::{Op, op}; + + fn assert_close(a: f64, b: f64) { + assert!((a - b).abs() < 1e-10, "{a} != {b}"); + } + + fn ket(values: &[Complex64]) -> DVector { + DVector::from_column_slice(values) + } + + fn pure_density(psi: &DVector) -> DMatrix { + psi * psi.adjoint() + } + + fn werner_state(p: f64) -> DMatrix { + let bell = ket(&[ + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + ]); + pure_density(&bell) * Complex64::new(p, 0.0) + + DMatrix::identity(4, 4) * Complex64::new((1.0 - p) / 4.0, 0.0) + } + + #[test] + fn pure_state_fidelity_matches_known_values() { + let zero = ket(&[Complex64::new(1.0, 0.0), Complex64::new(0.0, 0.0)]); + let one = ket(&[Complex64::new(0.0, 0.0), Complex64::new(1.0, 0.0)]); + let plus = ket(&[ + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + ]); + + assert_close(state_fidelity(&zero, &zero).unwrap(), 1.0); + assert_close(state_fidelity(&zero, &one).unwrap(), 0.0); + assert_close(state_fidelity(&zero, &plus).unwrap(), 0.5); + } + + #[test] + fn state_fidelity_rejects_unnormalized_vectors() { + let zero = ket(&[Complex64::new(1.0, 0.0), Complex64::new(0.0, 0.0)]); + let bad = ket(&[Complex64::new(1.0, 0.0), Complex64::new(1.0, 0.0)]); + + assert!(matches!( + state_fidelity(&zero, &bad).unwrap_err(), + MeasureError::InvalidStateNorm { .. } + )); + } + + #[test] + fn density_matrix_purity_and_entropy_match_known_states() { + let zero = ket(&[Complex64::new(1.0, 0.0), Complex64::new(0.0, 0.0)]); + let pure = pure_density(&zero); + let half = Complex64::new(0.5, 0.0); + let mixed = DMatrix::from_diagonal_element(2, 2, half); + + assert_close(purity(&pure).unwrap(), 1.0); + assert_close(entropy(&pure).unwrap(), 0.0); + assert_close(purity(&mixed).unwrap(), 0.5); + assert_close(entropy(&mixed).unwrap(), 1.0); + assert_close( + state_fidelity_with_density_matrix(&mixed, &zero).unwrap(), + 0.5, + ); + } + + #[test] + fn density_matrix_measures_reject_invalid_matrices() { + let non_square = DMatrix::from_element(2, 3, Complex64::new(0.0, 0.0)); + assert!(matches!( + purity(&non_square).unwrap_err(), + MeasureError::NonSquareMatrix { .. } + )); + + let mut non_hermitian = DMatrix::zeros(2, 2); + non_hermitian[(0, 0)] = Complex64::new(1.0, 0.0); + non_hermitian[(0, 1)] = Complex64::new(0.1, 0.0); + assert!(matches!( + purity(&non_hermitian).unwrap_err(), + MeasureError::NonHermitianMatrix { .. } + )); + } + + #[test] + fn two_qubit_entanglement_measures_match_known_states() { + let bell = ket(&[ + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + ]); + let bell_rho = pure_density(&bell); + assert_close(concurrence(&bell_rho).unwrap(), 1.0); + assert_close(entanglement_of_formation(&bell_rho).unwrap(), 1.0); + assert_close(mutual_information(&bell_rho, (2, 2)).unwrap(), 2.0); + + let zero_zero = ket(&[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + ]); + let product = pure_density(&zero_zero); + assert_close(concurrence(&product).unwrap(), 0.0); + assert_close(entanglement_of_formation(&product).unwrap(), 0.0); + assert_close(mutual_information(&product, (2, 2)).unwrap(), 0.0); + } + + #[test] + fn concurrence_matches_werner_state_threshold_formula() { + assert_close(concurrence(&werner_state(0.5)).unwrap(), 0.25); + assert_close(concurrence(&werner_state(0.3)).unwrap(), 0.0); + } + + #[test] + fn entanglement_of_formation_matches_intermediate_werner_state() { + let rho = werner_state(0.5); + assert_close(concurrence(&rho).unwrap(), 0.25); + assert_close( + entanglement_of_formation(&rho).unwrap(), + 0.117_618_873_770_917_81, + ); + } + + #[test] + fn negativity_matches_bell_and_product_states() { + let bell = ket(&[ + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + ]); + let bell_rho = pure_density(&bell); + assert_close(negativity(&bell_rho, &[2, 2], 1).unwrap(), 0.5); + assert_close(logarithmic_negativity(&bell_rho, &[2, 2], 1).unwrap(), 1.0); + + let product = pure_density(&ket(&[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + ])); + assert_close(negativity(&product, &[2, 2], 1).unwrap(), 0.0); + assert_close(logarithmic_negativity(&product, &[2, 2], 1).unwrap(), 0.0); + } + + #[test] + fn schmidt_decomposition_matches_bell_and_product_states() { + let bell = ket(&[ + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + ]); + let bell_terms = schmidt_decomposition(&bell, &[2, 2], &[0]).unwrap(); + assert_eq!(bell_terms.len(), 2); + assert_close(bell_terms[0].0, 1.0 / 2.0_f64.sqrt()); + assert_close(bell_terms[1].0, 1.0 / 2.0_f64.sqrt()); + + let product = ket(&[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + ]); + let product_terms = schmidt_decomposition(&product, &[2, 2], &[0]).unwrap(); + assert_eq!(product_terms.len(), 1); + assert_close(product_terms[0].0, 1.0); + } + + #[test] + fn schmidt_decomposition_supports_unequal_bipartition() { + let mut ghz = DVector::zeros(8); + ghz[0] = Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0); + ghz[7] = Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0); + + let terms = schmidt_decomposition(&ghz, &[2, 4], &[0]).unwrap(); + assert_eq!(terms.len(), 2); + assert_close(terms[0].0, 1.0 / 2.0_f64.sqrt()); + assert_close(terms[1].0, 1.0 / 2.0_f64.sqrt()); + assert_eq!(terms[0].1.len(), 2); + assert_eq!(terms[0].2.len(), 4); + } + + #[test] + fn mutual_information_accepts_non_qubit_subsystem_dims() { + let mut rho = DMatrix::zeros(6, 6); + rho[(0, 0)] = Complex64::new(0.5, 0.0); + rho[(5, 5)] = Complex64::new(0.5, 0.0); + + assert_close(mutual_information(&rho, (2, 3)).unwrap(), 1.0); + } + + #[test] + fn shannon_entropy_is_public_distribution_entropy() { + assert_close(shannon_entropy(&[0.5, 0.5], 2.0).unwrap(), 1.0); + assert_close(shannon_entropy(&[1.0, 0.0], 2.0).unwrap(), 0.0); + assert!(matches!( + shannon_entropy(&[0.25, 0.25], 2.0).unwrap_err(), + MeasureError::InvalidProbabilitySum { .. } + )); + } + + #[test] + fn hellinger_distance_and_fidelity_match_classical_cases() { + assert_close( + hellinger_distance(&[0.25, 0.75], &[0.25, 0.75]).unwrap(), + 0.0, + ); + assert_close( + hellinger_fidelity(&[0.25, 0.75], &[0.25, 0.75]).unwrap(), + 1.0, + ); + + assert_close(hellinger_distance(&[1.0, 0.0], &[0.0, 1.0]).unwrap(), 1.0); + assert_close(hellinger_fidelity(&[1.0, 0.0], &[0.0, 1.0]).unwrap(), 0.0); + } + + #[test] + fn partial_trace_supports_method_and_qubit_forms() { + let bell = ket(&[ + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0), + ]); + let bell_rho = pure_density(&bell); + + let reduced_from_method = bell_rho.partial_trace(&[2, 2], &[1]).unwrap(); + let reduced_from_qubits = partial_trace_qubits(&bell_rho, 2, &[1]).unwrap(); + let expected = DMatrix::from_diagonal_element(2, 2, Complex64::new(0.5, 0.0)); + + assert_close((&reduced_from_method - &expected).norm(), 0.0); + assert_close((&reduced_from_qubits - expected).norm(), 0.0); + } + + #[test] + fn partial_trace_subsystems_accepts_arbitrary_tensor_factors() { + let mut state = DVector::zeros(12); + state[2] = Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0); + state[9] = Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0); + let rho = pure_density(&state); + + let reduced = partial_trace_subsystems(&rho, &[2, 3, 2], &[1]).unwrap(); + let mut expected = DMatrix::zeros(4, 4); + expected[(0, 0)] = Complex64::new(0.5, 0.0); + expected[(0, 3)] = Complex64::new(0.5, 0.0); + expected[(3, 0)] = Complex64::new(0.5, 0.0); + expected[(3, 3)] = Complex64::new(0.5, 0.0); + + assert_close((reduced - expected).norm(), 0.0); + } + + #[test] + fn partial_trace_subsystems_can_trace_noncontiguous_factors() { + let mut state = DVector::zeros(12); + state[0] = Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0); + state[11] = Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0); + let rho = pure_density(&state); + + let reduced = partial_trace_subsystems(&rho, &[2, 3, 2], &[0, 2]).unwrap(); + let mut expected = DMatrix::zeros(3, 3); + expected[(0, 0)] = Complex64::new(0.5, 0.0); + expected[(2, 2)] = Complex64::new(0.5, 0.0); + + assert_close((reduced - expected).norm(), 0.0); + } + + #[test] + fn three_qubit_ghz_reductions_have_expected_information() { + let mut ghz = DVector::zeros(8); + ghz[0] = Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0); + ghz[7] = Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0); + let rho = pure_density(&ghz); + + let two_qubit_reduction = partial_trace_qubits(&rho, 3, &[2]).unwrap(); + let mut expected_two_qubit = DMatrix::zeros(4, 4); + expected_two_qubit[(0, 0)] = Complex64::new(0.5, 0.0); + expected_two_qubit[(3, 3)] = Complex64::new(0.5, 0.0); + assert_close((&two_qubit_reduction - &expected_two_qubit).norm(), 0.0); + assert_close(entropy(&two_qubit_reduction).unwrap(), 1.0); + assert_close( + mutual_information(&two_qubit_reduction, (2, 2)).unwrap(), + 1.0, + ); + + let one_qubit_reduction = partial_trace_qubits(&rho, 3, &[1, 2]).unwrap(); + let expected_one_qubit = DMatrix::from_diagonal_element(2, 2, Complex64::new(0.5, 0.0)); + assert_close((&one_qubit_reduction - expected_one_qubit).norm(), 0.0); + assert_close(entropy(&one_qubit_reduction).unwrap(), 1.0); + } + + #[test] + fn partial_trace_rejects_repeated_or_out_of_range_subsystems() { + let mixed = DMatrix::from_diagonal_element(4, 4, Complex64::new(0.25, 0.0)); + assert!(matches!( + partial_trace_subsystems(&mixed, &[2, 2], &[0, 0]).unwrap_err(), + MeasureError::DuplicateSubsystem { subsystem: 0 } + )); + assert!(matches!( + partial_trace_subsystems(&mixed, &[2, 2], &[2]).unwrap_err(), + MeasureError::SubsystemOutOfRange { + num_subsystems: 2, + subsystem: 2 + } + )); + } + + #[test] + fn entanglement_measures_reject_invalid_shapes() { + let mixed = DMatrix::from_diagonal_element(2, 2, Complex64::new(0.5, 0.0)); + assert!(matches!( + concurrence(&mixed).unwrap_err(), + MeasureError::InvalidMatrixShape { .. } + )); + assert!(matches!( + mutual_information(&mixed, (2, 2)).unwrap_err(), + MeasureError::InvalidSubsystemDimensions { .. } + )); + } + + #[test] + fn process_and_average_gate_fidelity_match_depolarizing_channel() { + let identity = Ptm::identity(1).unwrap(); + let Op::Channel(expr) = op::Depolarizing(0.3, 0) else { + panic!("expected channel"); + }; + let depolarizing = Ptm::from_channel_expr(&expr).unwrap(); + + assert_close(process_fidelity(&identity, &identity).unwrap(), 1.0); + assert_close(process_fidelity(&depolarizing, &identity).unwrap(), 0.7); + assert_close( + average_gate_fidelity(&depolarizing, &identity).unwrap(), + 0.8, + ); + assert_close(gate_error(&depolarizing, &identity).unwrap(), 0.2); + } + + #[test] + fn process_fidelity_reports_qubit_count_mismatch() { + let one_qubit = Ptm::identity(1).unwrap(); + let two_qubit = Ptm::identity(2).unwrap(); + + assert_eq!( + process_fidelity(&one_qubit, &two_qubit).unwrap_err(), + MeasureError::QubitCountMismatch { + expected: 1, + actual: 2 + } + ); + assert_eq!( + average_gate_fidelity(&one_qubit, &two_qubit).unwrap_err(), + MeasureError::QubitCountMismatch { + expected: 1, + actual: 2 + } + ); + assert_eq!( + gate_error(&one_qubit, &two_qubit).unwrap_err(), + MeasureError::QubitCountMismatch { + expected: 1, + actual: 2 + } + ); + } +} diff --git a/crates/pecos-quantum/src/operator_matrix.rs b/crates/pecos-quantum/src/operator_matrix.rs deleted file mode 100644 index 033e20019..000000000 --- a/crates/pecos-quantum/src/operator_matrix.rs +++ /dev/null @@ -1,1253 +0,0 @@ -// 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. - -//! Matrix representations and operations for quantum operators. -//! -//! This module provides functions to convert operators to dense matrices -//! and perform matrix-level operations like exponential and logarithm. -//! -//! # Extension Trait -//! -//! The [`ToMatrix`] trait provides a method-style API for converting operators: -//! -//! ``` -//! use pecos_quantum::operator_matrix::ToMatrix; -//! use pecos_core::operator::X; -//! -//! let x = X(0); -//! let matrix = x.to_matrix(); // Method style -//! ``` - -use nalgebra::DMatrix; -use num_complex::Complex64; -use pecos_core::gate_type::GateType; -use pecos_core::operator::{Operator, RotationType}; -use pecos_core::{Pauli, PauliString, Phase}; - -/// Extension trait for converting quantum operators to matrix representations. -/// -/// This trait is implemented for [`Operator`] and [`PauliString`], providing -/// a method-style API for matrix conversion. -/// -/// # Example -/// -/// ``` -/// use pecos_quantum::operator_matrix::ToMatrix; -/// use pecos_core::operator::{X, H, CX, Is}; -/// -/// // Single qubit gate -/// let x_matrix = X(0).to_matrix(); -/// assert_eq!(x_matrix.nrows(), 2); -/// -/// // Two qubit gate -/// let cnot_matrix = CX(0, 1).to_matrix(); -/// assert_eq!(cnot_matrix.nrows(), 4); -/// -/// // For larger matrices, tensor with identities using Is() -/// let x_extended = X(0) & Is(1..3); // X on qubit 0 in 3-qubit space -/// let mat = x_extended.to_matrix(); -/// assert_eq!(mat.nrows(), 8); -/// ``` -pub trait ToMatrix { - /// Converts to a dense matrix representation. - /// - /// The matrix size is 2^n where n is determined by the maximum qubit index + 1. - fn to_matrix(&self) -> DMatrix; -} - -impl ToMatrix for Operator { - fn to_matrix(&self) -> DMatrix { - to_matrix(self) - } -} - -impl ToMatrix for PauliString { - fn to_matrix(&self) -> DMatrix { - let num_qubits = self.qubits().into_iter().max().map_or(1, |q| q + 1); - pauli_string_to_matrix_impl(self, num_qubits) - } -} - -/// Converts an `Operator` to its dense matrix representation. -/// -/// The matrix size is 2^n where n is the number of qubits (determined by -/// the maximum qubit index + 1). -/// -/// # Example -/// -/// ``` -/// use pecos_quantum::operator_matrix::to_matrix; -/// use pecos_core::operator::X; -/// use num_complex::Complex64; -/// -/// let x = X(0); -/// let matrix = to_matrix(&x); -/// -/// // X gate matrix: [[0, 1], [1, 0]] -/// assert_eq!(matrix.nrows(), 2); -/// assert!((matrix[(0, 1)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); -/// ``` -#[must_use] -pub fn to_matrix(op: &Operator) -> DMatrix { - let num_qubits = op.qubits().into_iter().max().map_or(1, |q| q + 1); - to_matrix_with_size(op, num_qubits) -} - -/// Converts an `Operator` to its dense matrix representation with a specified size. -/// -/// # Arguments -/// * `op` - The operator to convert -/// * `num_qubits` - The number of qubits (matrix will be `2^num_qubits` x `2^num_qubits`) -#[must_use] -pub fn to_matrix_with_size(op: &Operator, num_qubits: usize) -> DMatrix { - let dim = 1 << num_qubits; // 2^num_qubits - - match op { - Operator::Pauli(ps) => pauli_string_to_matrix_impl(ps, num_qubits), - - Operator::Rotation { - rotation_type, - angle, - qubits, - } => rotation_to_matrix(*rotation_type, angle.to_radians(), qubits, num_qubits), - - Operator::Gate { gate_type, qubits } => gate_to_matrix(*gate_type, qubits, num_qubits), - - Operator::Tensor(parts) => { - // Start with identity, combine each part - let mut result = DMatrix::identity(dim, dim); - for part in parts { - let part_matrix = to_matrix_with_size(part, num_qubits); - result = combine_disjoint_operators(&result, &part_matrix); - } - result - } - - Operator::Compose(parts) => { - // Matrix multiplication in reverse order (last part applied first) - let mut result = DMatrix::identity(dim, dim); - for part in parts { - let part_matrix = to_matrix_with_size(part, num_qubits); - result = part_matrix * result; - } - result - } - - Operator::Adjoint(inner) => { - let inner_matrix = to_matrix_with_size(inner, num_qubits); - inner_matrix.adjoint() - } - - Operator::Phase { phase, inner } => { - let inner_matrix = to_matrix_with_size(inner, num_qubits); - let phase_factor = Complex64::new(0.0, phase.to_radians()).exp(); - inner_matrix * phase_factor - } - } -} - -/// Computes the matrix exponential of an operator: exp(i * op). -/// -/// This is useful for generating unitaries from Hermitian generators. -/// -/// # Example -/// -/// ``` -/// use pecos_quantum::operator_matrix::operator_exp; -/// use pecos_core::operator::Z; -/// use num_complex::Complex64; -/// use std::f64::consts::PI; -/// -/// // exp(i * pi * Z) = -I -/// let z = Z(0); -/// let result = operator_exp(&z, PI); -/// // Result should be approximately -I -/// ``` -#[must_use] -pub fn operator_exp(op: &Operator, theta: f64) -> DMatrix { - let matrix = to_matrix(op); - let scaled = matrix * Complex64::new(0.0, theta); - pecos_num::matrix_exp(&scaled) -} - -/// Computes the matrix logarithm of an operator. -/// -/// Returns `Some(generator)` where `exp(i * generator) = op`, or `None` if -/// the computation fails (e.g., for singular matrices). -/// -/// # Example -/// -/// ``` -/// use pecos_quantum::operator_matrix::{operator_log, to_matrix}; -/// use pecos_core::operator::X; -/// -/// let x = X(0); -/// if let Some(log_x) = operator_log(&x) { -/// // log_x is the generator such that exp(i * log_x) = X -/// } -/// ``` -#[must_use] -pub fn operator_log(op: &Operator) -> Option> { - let matrix = to_matrix(op); - let log_matrix = pecos_num::matrix_log(&matrix)?; - // Divide by i to get the Hermitian generator - Some(log_matrix / Complex64::new(0.0, 1.0)) -} - -/// Checks if two operators are equivalent up to a global phase. -/// -/// Returns `true` if A = e^{i*phi} * B for some real phi. -/// -/// # Example -/// -/// ``` -/// use pecos_quantum::operator_matrix::operators_equiv; -/// use pecos_core::operator::{X, Y, Z}; -/// -/// let x = X(0); -/// let x2 = X(0); -/// assert!(operators_equiv(&x, &x2)); -/// -/// let y = Y(0); -/// assert!(!operators_equiv(&x, &y)); -/// ``` -#[must_use] -pub fn operators_equiv(a: &Operator, b: &Operator) -> bool { - operators_equiv_with_tolerance(a, b, 1e-10) -} - -/// Checks if two operators are equivalent up to a global phase, with custom tolerance. -#[must_use] -pub fn operators_equiv_with_tolerance(a: &Operator, b: &Operator, tol: f64) -> bool { - let num_qubits_a = a.qubits().into_iter().max().map_or(1, |q| q + 1); - let num_qubits_b = b.qubits().into_iter().max().map_or(1, |q| q + 1); - let num_qubits = num_qubits_a.max(num_qubits_b); - - let mat_a = to_matrix_with_size(a, num_qubits); - let mat_b = to_matrix_with_size(b, num_qubits); - - matrices_equiv_up_to_phase(&mat_a, &mat_b, tol) -} - -/// Checks if two matrices are equal up to a global phase factor. -fn matrices_equiv_up_to_phase(a: &DMatrix, b: &DMatrix, tol: f64) -> bool { - if a.nrows() != b.nrows() || a.ncols() != b.ncols() { - return false; - } - - // Find the first non-zero element to determine the phase - let mut phase: Option = None; - - for i in 0..a.nrows() { - for j in 0..a.ncols() { - let a_val = a[(i, j)]; - let b_val = b[(i, j)]; - - // Skip near-zero elements - if a_val.norm() < tol && b_val.norm() < tol { - continue; - } - - // If one is zero but not the other, not equivalent - if a_val.norm() < tol || b_val.norm() < tol { - return false; - } - - // Compute the ratio a/b - let ratio = a_val / b_val; - - match phase { - None => { - // First non-zero element sets the phase - phase = Some(ratio); - } - Some(p) => { - // Check if this ratio matches the established phase - if (ratio - p).norm() > tol { - return false; - } - } - } - } - } - - // Also verify the phase has unit magnitude (global phase factor) - if let Some(p) = phase { - (p.norm() - 1.0).abs() < tol - } else { - // Both matrices are zero - true - } -} - -// --- Helper functions for matrix construction --- - -/// Converts a [`PauliString`] to a dense matrix (implementation). -fn pauli_string_to_matrix_impl(ps: &PauliString, num_qubits: usize) -> DMatrix { - let dim = 1 << num_qubits; - let mut result = DMatrix::identity(dim, dim); - - // Get the phase - let phase = ps.phase().to_complex(); - - // Apply each single-qubit Pauli - for (pauli, qubit) in ps.iter_pairs() { - let q = usize::from(qubit); - let pauli_matrix = single_pauli_matrix(pauli); - let full_matrix = embed_single_qubit_gate(&pauli_matrix, q, num_qubits); - result = full_matrix * result; - } - - result * phase -} - -/// Returns the 2x2 matrix for a single Pauli operator. -fn single_pauli_matrix(pauli: Pauli) -> DMatrix { - let zero = Complex64::new(0.0, 0.0); - let one = Complex64::new(1.0, 0.0); - let i = Complex64::new(0.0, 1.0); - let neg_i = Complex64::new(0.0, -1.0); - let neg_one = Complex64::new(-1.0, 0.0); - - match pauli { - Pauli::I => DMatrix::from_row_slice(2, 2, &[one, zero, zero, one]), - Pauli::X => DMatrix::from_row_slice(2, 2, &[zero, one, one, zero]), - Pauli::Y => DMatrix::from_row_slice(2, 2, &[zero, neg_i, i, zero]), - Pauli::Z => DMatrix::from_row_slice(2, 2, &[one, zero, zero, neg_one]), - } -} - -/// Embeds a single-qubit gate into a larger Hilbert space. -fn embed_single_qubit_gate( - gate: &DMatrix, - qubit: usize, - num_qubits: usize, -) -> DMatrix { - let dim = 1 << num_qubits; - let mut result = DMatrix::from_element(dim, dim, Complex64::new(0.0, 0.0)); - - for i in 0..dim { - for j in 0..dim { - // Check if all qubits except `qubit` match - let mask = !(1 << qubit); - if (i & mask) == (j & mask) { - let i_bit = (i >> qubit) & 1; - let j_bit = (j >> qubit) & 1; - result[(i, j)] = gate[(i_bit, j_bit)]; - } - } - } - - result -} - -/// Converts a rotation to a matrix. -fn rotation_to_matrix( - rotation_type: RotationType, - angle: f64, - qubits: &[usize], - num_qubits: usize, -) -> DMatrix { - let half_angle = angle / 2.0; - let cos_half = Complex64::new(half_angle.cos(), 0.0); - let sin_half = Complex64::new(half_angle.sin(), 0.0); - let i = Complex64::new(0.0, 1.0); - let neg_i = Complex64::new(0.0, -1.0); - - match rotation_type { - RotationType::RX => { - // RX(θ) = cos(θ/2)I - i*sin(θ/2)X - let gate = DMatrix::from_row_slice( - 2, - 2, - &[cos_half, neg_i * sin_half, neg_i * sin_half, cos_half], - ); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - RotationType::RY => { - // RY(θ) = cos(θ/2)I - i*sin(θ/2)Y - let gate = DMatrix::from_row_slice(2, 2, &[cos_half, -sin_half, sin_half, cos_half]); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - RotationType::RZ => { - // RZ(θ) = cos(θ/2)I - i*sin(θ/2)Z = diag(e^{-iθ/2}, e^{iθ/2}) - let exp_neg = (neg_i * Complex64::new(half_angle, 0.0)).exp(); - let exp_pos = (i * Complex64::new(half_angle, 0.0)).exp(); - let zero = Complex64::new(0.0, 0.0); - let gate = DMatrix::from_row_slice(2, 2, &[exp_neg, zero, zero, exp_pos]); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - RotationType::RXX | RotationType::RYY | RotationType::RZZ => { - // For two-qubit rotations, use matrix exponential - let dim = 1 << num_qubits; - let generator = match rotation_type { - RotationType::RXX => { - two_qubit_pauli_matrix(Pauli::X, Pauli::X, qubits[0], qubits[1], num_qubits) - } - RotationType::RYY => { - two_qubit_pauli_matrix(Pauli::Y, Pauli::Y, qubits[0], qubits[1], num_qubits) - } - RotationType::RZZ => { - two_qubit_pauli_matrix(Pauli::Z, Pauli::Z, qubits[0], qubits[1], num_qubits) - } - _ => DMatrix::identity(dim, dim), - }; - let scaled = generator * Complex64::new(0.0, -half_angle); - pecos_num::matrix_exp(&scaled) - } - } -} - -/// Constructs a two-qubit Pauli tensor product matrix. -fn two_qubit_pauli_matrix( - p1: Pauli, - p2: Pauli, - q1: usize, - q2: usize, - num_qubits: usize, -) -> DMatrix { - let m1 = single_pauli_matrix(p1); - let m2 = single_pauli_matrix(p2); - let e1 = embed_single_qubit_gate(&m1, q1, num_qubits); - let e2 = embed_single_qubit_gate(&m2, q2, num_qubits); - e1 * e2 -} - -/// Converts a gate type to a matrix. -fn gate_to_matrix(gate_type: GateType, qubits: &[usize], num_qubits: usize) -> DMatrix { - let zero = Complex64::new(0.0, 0.0); - let one = Complex64::new(1.0, 0.0); - let i = Complex64::new(0.0, 1.0); - let neg_i = Complex64::new(0.0, -1.0); - let neg_one = Complex64::new(-1.0, 0.0); - let sqrt2_inv = Complex64::new(1.0 / 2.0_f64.sqrt(), 0.0); - - match gate_type { - GateType::I => { - let gate = DMatrix::from_row_slice(2, 2, &[one, zero, zero, one]); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - GateType::X => { - let gate = DMatrix::from_row_slice(2, 2, &[zero, one, one, zero]); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - GateType::Y => { - let gate = DMatrix::from_row_slice(2, 2, &[zero, neg_i, i, zero]); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - GateType::Z => { - let gate = DMatrix::from_row_slice(2, 2, &[one, zero, zero, neg_one]); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - GateType::H => { - let gate = - DMatrix::from_row_slice(2, 2, &[sqrt2_inv, sqrt2_inv, sqrt2_inv, -sqrt2_inv]); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - GateType::SX => { - // SX = (1+i)/2 * [[1, -i], [-i, 1]] - let factor = Complex64::new(0.5, 0.5); - let gate = DMatrix::from_row_slice( - 2, - 2, - &[factor * one, factor * neg_i, factor * neg_i, factor * one], - ); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - GateType::SXdg => { - let factor = Complex64::new(0.5, -0.5); - let gate = DMatrix::from_row_slice( - 2, - 2, - &[factor * one, factor * i, factor * i, factor * one], - ); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - GateType::SZ => { - // S = diag(1, i) - let gate = DMatrix::from_row_slice(2, 2, &[one, zero, zero, i]); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - GateType::SZdg => { - let gate = DMatrix::from_row_slice(2, 2, &[one, zero, zero, neg_i]); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - GateType::T => { - // T = diag(1, e^{i*pi/4}) - let exp_pi_4 = Complex64::from_polar(1.0, std::f64::consts::FRAC_PI_4); - let gate = DMatrix::from_row_slice(2, 2, &[one, zero, zero, exp_pi_4]); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - GateType::Tdg => { - let exp_neg_pi_4 = Complex64::from_polar(1.0, -std::f64::consts::FRAC_PI_4); - let gate = DMatrix::from_row_slice(2, 2, &[one, zero, zero, exp_neg_pi_4]); - embed_single_qubit_gate(&gate, qubits[0], num_qubits) - } - GateType::CX => controlled_gate( - &single_pauli_matrix(Pauli::X), - qubits[0], - qubits[1], - num_qubits, - ), - GateType::CY => controlled_gate( - &single_pauli_matrix(Pauli::Y), - qubits[0], - qubits[1], - num_qubits, - ), - GateType::CZ => controlled_gate( - &single_pauli_matrix(Pauli::Z), - qubits[0], - qubits[1], - num_qubits, - ), - GateType::SWAP => swap_matrix(qubits[0], qubits[1], num_qubits), - _ => { - // Gates not yet implemented: SY, SYdg, U, R1XY, SZZ, SZZdg, CRZ, CCX - // Rotation gates (RX, RY, RZ, RXX, RYY, RZZ) should use Operator::Rotation - // Prep/Measure gates are not unitary and shouldn't be converted to matrices - log::warn!("Gate type {gate_type:?} not implemented in to_matrix, returning identity"); - let dim = 1 << num_qubits; - DMatrix::identity(dim, dim) - } - } -} - -/// Constructs a controlled gate matrix. -fn controlled_gate( - target_gate: &DMatrix, - control: usize, - target: usize, - num_qubits: usize, -) -> DMatrix { - let dim = 1 << num_qubits; - let mut result = DMatrix::identity(dim, dim); - - for i in 0..dim { - for j in 0..dim { - // Only apply gate when control qubit is 1 - let control_bit_i = (i >> control) & 1; - let control_bit_j = (j >> control) & 1; - - if control_bit_i == 1 && control_bit_j == 1 { - // Check if all qubits except target match - let mask = !(1 << target); - if (i & mask) == (j & mask) { - let i_bit = (i >> target) & 1; - let j_bit = (j >> target) & 1; - result[(i, j)] = target_gate[(i_bit, j_bit)]; - } else { - result[(i, j)] = Complex64::new(0.0, 0.0); - } - } else if control_bit_i == control_bit_j && i == j { - result[(i, j)] = Complex64::new(1.0, 0.0); - } else if control_bit_i != control_bit_j { - result[(i, j)] = Complex64::new(0.0, 0.0); - } - } - } - - result -} - -/// Constructs a SWAP gate matrix. -fn swap_matrix(q1: usize, q2: usize, num_qubits: usize) -> DMatrix { - let dim = 1 << num_qubits; - let mut result = DMatrix::from_element(dim, dim, Complex64::new(0.0, 0.0)); - - for i in 0..dim { - // Swap bits at positions q1 and q2 - let bit1 = (i >> q1) & 1; - let bit2 = (i >> q2) & 1; - - let j = if bit1 == bit2 { - i - } else { - // Swap the bits - i ^ (1 << q1) ^ (1 << q2) - }; - - result[(i, j)] = Complex64::new(1.0, 0.0); - } - - result -} - -/// Combines two matrices representing operators on disjoint qubits. -/// -/// When operators act on disjoint qubits, the tensor product in the full Hilbert space -/// is equivalent to matrix multiplication (since disjoint operators commute). -fn combine_disjoint_operators( - a: &DMatrix, - b: &DMatrix, -) -> DMatrix { - a * b -} - -#[cfg(test)] -mod tests { - use super::*; - use pecos_core::Angle64; - use pecos_core::operator::{CX, H, I, Is, RX, RZ, SWAP, SZ, T, X, Y, Z}; - use std::f64::consts::PI; - - // --- Basic to_matrix tests --- - - #[test] - fn test_pauli_matrices() { - let x = X(0); - let mat = to_matrix(&x); - assert_eq!(mat.nrows(), 2); - assert!((mat[(0, 1)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(1, 0)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - - let z = Z(0); - let mat = to_matrix(&z); - assert!((mat[(0, 0)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(1, 1)] - Complex64::new(-1.0, 0.0)).norm() < 1e-10); - } - - #[test] - fn test_pauli_y() { - let y = Y(0); - let mat = to_matrix(&y); - let i = Complex64::new(0.0, 1.0); - assert!((mat[(0, 1)] - (-i)).norm() < 1e-10); - assert!((mat[(1, 0)] - i).norm() < 1e-10); - } - - #[test] - fn test_hadamard() { - let h = H(0); - let mat = to_matrix(&h); - let sqrt2_inv = 1.0 / 2.0_f64.sqrt(); - assert!((mat[(0, 0)] - Complex64::new(sqrt2_inv, 0.0)).norm() < 1e-10); - assert!((mat[(0, 1)] - Complex64::new(sqrt2_inv, 0.0)).norm() < 1e-10); - assert!((mat[(1, 0)] - Complex64::new(sqrt2_inv, 0.0)).norm() < 1e-10); - assert!((mat[(1, 1)] - Complex64::new(-sqrt2_inv, 0.0)).norm() < 1e-10); - } - - #[test] - fn test_cnot() { - let cx = CX(0, 1); - let mat = to_matrix(&cx); - assert_eq!(mat.nrows(), 4); - // CX with control=0, target=1 - // |q1 q0> indexing: index = q1*2 + q0 - // When q0=0 (control off): do nothing - // When q0=1 (control on): flip q1 - // |00> -> |00> (mat[0,0] = 1) - // |01> -> |11> (mat[3,1] = 1) - // |10> -> |10> (mat[2,2] = 1) - // |11> -> |01> (mat[1,3] = 1) - assert!((mat[(0, 0)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(3, 1)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(2, 2)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(1, 3)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - } - - #[test] - fn test_identity() { - let id = I(0); - let mat = to_matrix(&id); - assert!((mat[(0, 0)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(1, 1)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!(mat[(0, 1)].norm() < 1e-10); - assert!(mat[(1, 0)].norm() < 1e-10); - } - - // --- Rotation matrix tests --- - - #[test] - fn test_t_gate_matrix() { - let t = T(0); - let mat = to_matrix(&t); - // T = RZ(π/4) = diag(e^{-iπ/8}, e^{iπ/8}) - let exp_neg = Complex64::from_polar(1.0, -PI / 8.0); - let exp_pos = Complex64::from_polar(1.0, PI / 8.0); - assert!((mat[(0, 0)] - exp_neg).norm() < 1e-10); - assert!((mat[(1, 1)] - exp_pos).norm() < 1e-10); - } - - #[test] - fn test_s_gate_matrix() { - let s = SZ(0); - let mat = to_matrix(&s); - // S = RZ(π/2) = diag(e^{-iπ/4}, e^{iπ/4}) - let exp_neg = Complex64::from_polar(1.0, -PI / 4.0); - let exp_pos = Complex64::from_polar(1.0, PI / 4.0); - assert!((mat[(0, 0)] - exp_neg).norm() < 1e-10); - assert!((mat[(1, 1)] - exp_pos).norm() < 1e-10); - } - - #[test] - fn test_rx_matrix() { - // RX(π) should give X (up to global phase) - let rx_pi = RX(Angle64::HALF_TURN, 0); - let mat = to_matrix(&rx_pi); - let x_mat = to_matrix(&X(0)); - // RX(π) = -iX, so matrices differ by global phase -i - assert!(matrices_equiv_up_to_phase(&mat, &x_mat, 1e-10)); - } - - #[test] - fn test_rz_matrix() { - // RZ(π) should give Z (up to global phase) - let rz_pi = RZ(Angle64::HALF_TURN, 0); - let mat = to_matrix(&rz_pi); - let z_mat = to_matrix(&Z(0)); - assert!(matrices_equiv_up_to_phase(&mat, &z_mat, 1e-10)); - } - - // --- Tensor product and composition tests --- - - #[test] - fn test_tensor_product() { - // X ⊗ Z should give a 4x4 matrix - let xz = X(0) & Z(1); - let mat = to_matrix(&xz); - assert_eq!(mat.nrows(), 4); - - // Verify it's the product of embedded X and Z - let x_embedded = to_matrix_with_size(&X(0), 2); - let z_embedded = to_matrix_with_size(&Z(1), 2); - let expected = &x_embedded * &z_embedded; - assert!(matrices_equiv_up_to_phase(&mat, &expected, 1e-10)); - } - - #[test] - fn test_composition() { - // H * X = XH (matrix multiplication order) - let hx = H(0) * X(0); - let mat = to_matrix(&hx); - - let h_mat = to_matrix(&H(0)); - let x_mat = to_matrix(&X(0)); - let expected = &h_mat * &x_mat; - assert!(matrices_equiv_up_to_phase(&mat, &expected, 1e-10)); - } - - #[test] - fn test_adjoint_matrix() { - // T† matrix should be conjugate transpose of T - let t = T(0); - let t_dg = t.dg(); - let mat_t = to_matrix(&t); - let mat_t_dg = to_matrix(&t_dg); - - let expected = mat_t.adjoint(); - assert!(matrices_equiv_up_to_phase(&mat_t_dg, &expected, 1e-10)); - } - - #[test] - fn test_swap_gate() { - let swap = SWAP(0, 1); - let mat = to_matrix(&swap); - assert_eq!(mat.nrows(), 4); - - // SWAP|00> = |00>, SWAP|01> = |10>, SWAP|10> = |01>, SWAP|11> = |11> - assert!((mat[(0, 0)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(2, 1)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(1, 2)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(3, 3)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - } - - // --- operators_equiv tests --- - - #[test] - fn test_operators_equiv_same() { - let x1 = X(0); - let x2 = X(0); - assert!(operators_equiv(&x1, &x2)); - } - - #[test] - fn test_operators_equiv_different() { - let x = X(0); - let y = Y(0); - assert!(!operators_equiv(&x, &y)); - } - - #[test] - fn test_operators_equiv_global_phase() { - // X and -X differ by global phase -1 - let x = X(0); - let neg_x = pecos_core::operator::phase(Angle64::HALF_TURN) * X(0); - assert!(operators_equiv(&x, &neg_x)); - } - - #[test] - fn test_operators_equiv_i_phase() { - // X and iX differ by global phase i - let x = X(0); - let i_x = pecos_core::operator::i * X(0); - assert!(operators_equiv(&x, &i_x)); - } - - // --- operator_exp tests --- - - #[test] - fn test_operator_exp_identity() { - // exp(i * 0 * X) = I - let x = X(0); - let result = operator_exp(&x, 0.0); - let identity = DMatrix::identity(2, 2); - assert!(matrices_equiv_up_to_phase(&result, &identity, 1e-10)); - } - - #[test] - fn test_operator_exp_pauli_pi() { - // exp(i * π * Z) = -I - let z = Z(0); - let result = operator_exp(&z, PI); - let neg_identity: DMatrix = DMatrix::identity(2, 2) * Complex64::new(-1.0, 0.0); - assert!(matrices_equiv_up_to_phase(&result, &neg_identity, 1e-10)); - } - - #[test] - fn test_operator_exp_pauli_half_pi() { - // exp(i * π/2 * X) = i*X = [[0, i], [i, 0]] - let x = X(0); - let result = operator_exp(&x, PI / 2.0); - let i = Complex64::new(0.0, 1.0); - let expected = to_matrix(&x) * i; - assert!(matrices_equiv_up_to_phase(&result, &expected, 1e-10)); - } - - // --- operator_log tests --- - - #[test] - fn test_operator_log_identity() { - // log(I) = 0 - let id = I(0); - let result = operator_log(&id); - assert!(result.is_some()); - let log_mat = result.unwrap(); - // All elements should be near zero - for i in 0..log_mat.nrows() { - for j in 0..log_mat.ncols() { - assert!(log_mat[(i, j)].norm() < 1e-8); - } - } - } - - #[test] - fn test_operator_log_returns_matrix() { - // log(T) should exist (T is close to identity) - let t = T(0); - let result = operator_log(&t); - assert!(result.is_some()); - - // log(S) should exist - let s = SZ(0); - let result = operator_log(&s); - assert!(result.is_some()); - } - - // --- to_matrix_with_size tests --- - - #[test] - fn test_to_matrix_with_size_embedding() { - // X(0) in 3-qubit space should be 8x8 - let x = X(0); - let mat = to_matrix_with_size(&x, 3); - assert_eq!(mat.nrows(), 8); - - // Should act as X on qubit 0, identity on others - // Check that |000> -> |001>, |001> -> |000> - assert!((mat[(1, 0)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(0, 1)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - } - - #[test] - fn test_to_matrix_preserves_unitarity() { - // Verify U * U† = I for various operators - let operators = vec![X(0), Y(0), Z(0), H(0), T(0), CX(0, 1)]; - - for op in operators { - let mat = to_matrix(&op); - let product = &mat * mat.adjoint(); - let identity: DMatrix = DMatrix::identity(mat.nrows(), mat.ncols()); - - for i in 0..mat.nrows() { - for j in 0..mat.ncols() { - assert!( - (product[(i, j)] - identity[(i, j)]).norm() < 1e-10, - "Unitarity failed for operator at ({i}, {j})" - ); - } - } - } - } - - // --- Conjugation matrix verification tests --- - - #[test] - fn test_conj_matrix_verification() { - // Verify A.conj(U) = U * A * U† via matrices - let a = X(0); - let u = H(0); - - let conj_result = a.conj(&u); - let conj_mat = to_matrix(&conj_result); - - // Compute U * A * U† directly - let u_mat = to_matrix(&u); - let a_mat = to_matrix(&a); - let expected = &u_mat * &a_mat * u_mat.adjoint(); - - assert!(matrices_equiv_up_to_phase(&conj_mat, &expected, 1e-10)); - } - - #[test] - fn test_conjdg_matrix_verification() { - // Verify A.conjdg(U) = U† * A * U via matrices - let a = X(0); - let u = H(0); - - let conjdg_result = a.conjdg(&u); - let conjdg_mat = to_matrix(&conjdg_result); - - // Compute U† * A * U directly - let u_mat = to_matrix(&u); - let a_mat = to_matrix(&a); - let expected = u_mat.adjoint() * &a_mat * &u_mat; - - assert!(matrices_equiv_up_to_phase(&conjdg_mat, &expected, 1e-10)); - } - - #[test] - fn test_conj_sz_gives_y() { - // X.conj(SZ) = SZ * X * SZ† should equal Y (up to phase) - let x = X(0); - let sz = SZ(0); - - let conj_result = x.conj(&sz); - let conj_mat = to_matrix(&conj_result); - - let y_mat = to_matrix(&Y(0)); - - assert!(matrices_equiv_up_to_phase(&conj_mat, &y_mat, 1e-10)); - } - - #[test] - fn test_conj_conjdg_inverse_via_matrix() { - // A.conj(U).conjdg(U) should equal A - let a = X(0); - let u = T(0); - - let forward = a.clone().conj(&u); - let back = forward.conjdg(&u); - let back_mat = to_matrix(&back); - - let a_mat = to_matrix(&a); - - assert!(matrices_equiv_up_to_phase(&back_mat, &a_mat, 1e-10)); - } - - // --- Multi-qubit conjugation tests --- - - #[test] - fn test_conj_multi_qubit_stabilizer() { - // Two-qubit stabilizer X⊗Z conjugated by CNOT - let stabilizer = X(0) & Z(1); - let cnot = CX(0, 1); - - let updated = stabilizer.conj(&cnot); - let updated_mat = to_matrix(&updated); - - // Compute CNOT * (X⊗Z) * CNOT† directly - let cnot_mat = to_matrix(&cnot); - let stab_mat = to_matrix(&stabilizer); - let expected = &cnot_mat * &stab_mat * cnot_mat.adjoint(); - - assert!(matrices_equiv_up_to_phase(&updated_mat, &expected, 1e-10)); - } - - #[test] - fn test_conj_by_two_qubit_gate() { - // Single-qubit Pauli conjugated by two-qubit gate - let x = X(0); - let cnot = CX(0, 1); - - let result = x.conj(&cnot); - let result_mat = to_matrix(&result); - - // CNOT * X(0) * CNOT† = X(0) ⊗ X(1) (CNOT propagates X from control to target) - let xx = X(0) & X(1); - let expected = to_matrix(&xx); - - assert!(matrices_equiv_up_to_phase(&result_mat, &expected, 1e-10)); - } - - // --- More two-qubit gate tests --- - - #[test] - fn test_cz_gate() { - // CZ = |0><0| ⊗ I + |1><1| ⊗ Z - use pecos_core::operator::CZ; - let cz = CZ(0, 1); - let mat = to_matrix(&cz); - - // CZ matrix: diag(1, 1, 1, -1) - assert!((mat[(0, 0)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(1, 1)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(2, 2)] - Complex64::new(1.0, 0.0)).norm() < 1e-10); - assert!((mat[(3, 3)] - Complex64::new(-1.0, 0.0)).norm() < 1e-10); - - // Off-diagonal should be zero - assert!(mat[(0, 1)].norm() < 1e-10); - assert!(mat[(1, 2)].norm() < 1e-10); - } - - #[test] - fn test_cz_symmetric() { - // CZ(0,1) should equal CZ(1,0) - use pecos_core::operator::CZ; - let cz_01 = CZ(0, 1); - let cz_10 = CZ(1, 0); - - let mat_01 = to_matrix(&cz_01); - let mat_10 = to_matrix(&cz_10); - - assert!(matrices_equiv_up_to_phase(&mat_01, &mat_10, 1e-10)); - } - - // --- Algebraic identity tests --- - - #[test] - fn test_adjoint_of_product() { - // (AB)† = B†A† - let a = H(0); - let b = T(0); - - let ab = a.clone() * b.clone(); - let ab_dagger = ab.dg(); - let ab_dagger_mat = to_matrix(&ab_dagger); - - let b_dagger_a_dagger = b.dg() * a.dg(); - let expected = to_matrix(&b_dagger_a_dagger); - - assert!(matrices_equiv_up_to_phase(&ab_dagger_mat, &expected, 1e-10)); - } - - #[test] - fn test_double_adjoint_identity() { - // (A†)† = A - let ops = vec![X(0), Y(0), H(0), T(0), CX(0, 1)]; - - for op in ops { - let double_dagger = op.dg().dg(); - let original_mat = to_matrix(&op); - let double_mat = to_matrix(&double_dagger); - - assert!(matrices_equiv_up_to_phase( - &original_mat, - &double_mat, - 1e-10 - )); - } - } - - #[test] - fn test_tensor_adjoint() { - // (A ⊗ B)† = A† ⊗ B† - let a = H(0); - let b = T(1); - - let tensor = a.clone() & b.clone(); - let tensor_dagger = tensor.dg(); - let tensor_dagger_mat = to_matrix(&tensor_dagger); - - let a_dagger_tensor_b_dagger = a.dg() & b.dg(); - let expected = to_matrix(&a_dagger_tensor_b_dagger); - - assert!(matrices_equiv_up_to_phase( - &tensor_dagger_mat, - &expected, - 1e-10 - )); - } - - // --- ToMatrix trait tests --- - - #[test] - fn test_to_matrix_trait_method() { - // Test that trait method gives same result as standalone function - let h = H(0); - - let via_function = to_matrix(&h); - let via_trait = h.to_matrix(); - - assert_eq!(via_function.nrows(), via_trait.nrows()); - assert_eq!(via_function.ncols(), via_trait.ncols()); - for i in 0..via_function.nrows() { - for j in 0..via_function.ncols() { - assert!((via_function[(i, j)] - via_trait[(i, j)]).norm() < 1e-10); - } - } - } - - #[test] - fn test_to_matrix_with_identity_tensor() { - // Test using Is() to get larger matrix - let x_extended = X(0) & Is(1..3); // X on qubit 0 in 3-qubit space - - let mat = x_extended.to_matrix(); - assert_eq!(mat.nrows(), 8); // 2^3 = 8 - - // Should match the standalone function - let expected = to_matrix_with_size(&X(0), 3); - assert!(matrices_equiv_up_to_phase(&mat, &expected, 1e-10)); - } - - #[test] - fn test_to_matrix_trait_chaining() { - // Verify trait works well with operator chaining - let circuit = H(0) * CX(0, 1) * H(0); - let mat = circuit.to_matrix(); - - assert_eq!(mat.nrows(), 4); // 2 qubits - - // Verify unitarity - let product = &mat * mat.adjoint(); - let identity: DMatrix = DMatrix::identity(4, 4); - assert!(matrices_equiv_up_to_phase(&product, &identity, 1e-10)); - } - - // --- Identity operator ToMatrix tests --- - - #[test] - fn test_identity_to_matrix_single_qubit() { - // I(0).to_matrix() should be 2x2 identity - let mat = I(0).to_matrix(); - let expected: DMatrix = DMatrix::identity(2, 2); - - assert_eq!(mat.nrows(), 2); - assert!(matrices_equiv_up_to_phase(&mat, &expected, 1e-10)); - } - - #[test] - fn test_identity_to_matrix_two_qubits() { - // Is(0..=1).to_matrix() should be 4x4 identity - let mat = Is(0..=1).to_matrix(); - let expected: DMatrix = DMatrix::identity(4, 4); - - assert_eq!(mat.nrows(), 4); - assert!(matrices_equiv_up_to_phase(&mat, &expected, 1e-10)); - } - - #[test] - fn test_identity_tensor_with_gate() { - // X(0) & I(1) should give X tensor I = 4x4 matrix - let op = X(0) & I(1); - let mat = op.to_matrix(); - - assert_eq!(mat.nrows(), 4); - - // Should equal X(0) extended to 2 qubits - let expected = to_matrix_with_size(&X(0), 2); - - assert!(matrices_equiv_up_to_phase(&mat, &expected, 1e-10)); - } - - #[test] - fn test_simplify_preserves_tensor_dimension() { - // (X(0) & I(1)).simplify() should preserve the 2-qubit space - let op = X(0) & I(1); - let simplified = op.simplify(); - - // Both should produce equivalent 4x4 matrices - let orig_mat = op.to_matrix(); - let simp_mat = simplified.to_matrix(); - - assert_eq!(orig_mat.nrows(), 4); - assert_eq!(simp_mat.nrows(), 4); - assert!(matrices_equiv_up_to_phase(&orig_mat, &simp_mat, 1e-10)); - } - - // --- PauliString ToMatrix tests --- - - #[test] - fn test_pauli_string_to_matrix_single() { - use pecos_core::PauliString; - - // Single X Pauli - let ps = PauliString::x(0); - let mat = ps.to_matrix(); - - // Should match X(0).to_matrix() - let x_mat = X(0).to_matrix(); - assert!(matrices_equiv_up_to_phase(&mat, &x_mat, 1e-10)); - } - - #[test] - fn test_pauli_string_to_matrix_multi() { - use pecos_core::{Pauli, PauliString}; - - // X on qubit 0, Z on qubit 1 - let ps = PauliString::from_paulis(&[Pauli::X, Pauli::Z]); - let mat = ps.to_matrix(); - - // Should match (X(0) & Z(1)).to_matrix() - let xz = X(0) & Z(1); - let expected = xz.to_matrix(); - assert!(matrices_equiv_up_to_phase(&mat, &expected, 1e-10)); - } - - #[test] - fn test_pauli_string_to_matrix_with_phase() { - use pecos_core::{Pauli, PauliString, QuarterPhase}; - - // -i * X - let ps = PauliString::from_paulis_with_phase(QuarterPhase::MinusI, &[Pauli::X]); - let mat = ps.to_matrix(); - - // Should be -i times X matrix - let x_mat = X(0).to_matrix(); - let neg_i = Complex64::new(0.0, -1.0); - let expected = x_mat * neg_i; - - assert!(matrices_equiv_up_to_phase(&mat, &expected, 1e-10)); - } - - #[test] - fn test_pauli_string_to_matrix_identity() { - use pecos_core::PauliString; - - // Identity PauliString - returns 1x1 identity (no qubits) - let ps = PauliString::identity(); - let mat = ps.to_matrix(); - - // Identity with no qubits defaults to 1 qubit -> 2x2 - let identity: DMatrix = DMatrix::identity(2, 2); - assert!(matrices_equiv_up_to_phase(&mat, &identity, 1e-10)); - } - - #[test] - fn test_pauli_string_matches_operator_pauli() { - use pecos_core::{Pauli, PauliString}; - - // Verify PauliString.to_matrix() matches Operator::Pauli.to_matrix() - let ps = PauliString::from_paulis(&[Pauli::Y, Pauli::Z]); - - // Convert to Operator::Pauli - let op = pecos_core::operator::Operator::Pauli(ps.clone()); - - let ps_mat = ps.to_matrix(); - let op_mat = op.to_matrix(); - - assert!(matrices_equiv_up_to_phase(&ps_mat, &op_mat, 1e-10)); - } -} diff --git a/crates/pecos-quantum/src/pass.rs b/crates/pecos-quantum/src/pass.rs index 7b5546346..98160d49b 100644 --- a/crates/pecos-quantum/src/pass.rs +++ b/crates/pecos-quantum/src/pass.rs @@ -23,16 +23,107 @@ use std::collections::{BTreeMap, HashMap, HashSet}; use pecos_core::gate_type::GateType; use pecos_core::{Angle64, Gate, GateQubits, QubitId}; -use crate::{Attribute, DagCircuit, TickCircuit}; +use crate::{Attribute, DagCircuit, Tick, TickCircuit}; /// A transformation pass that can be applied to circuits. +/// +/// Passes transform circuits in-place. For a copy, clone the circuit first: +/// +/// ```no_run +/// # use pecos_quantum::pass::{CircuitPass, SimplifyRotations}; +/// # use pecos_quantum::TickCircuit; +/// # let circuit = TickCircuit::new(); +/// // In-place +/// let mut tc = circuit; +/// SimplifyRotations.apply_tick(&mut tc); +/// +/// // Copy (clone first) +/// # let original = TickCircuit::new(); +/// let transformed = SimplifyRotations.transform_tick(original); +/// ``` pub trait CircuitPass { - /// Apply this pass to a [`TickCircuit`]. + /// Apply this pass to a [`TickCircuit`] in-place. fn apply_tick(&self, circuit: &mut TickCircuit); - /// Apply this pass to a [`DagCircuit`]. + + /// Apply this pass to a [`DagCircuit`] in-place. fn apply_dag(&self, circuit: &mut DagCircuit); + + /// Take ownership, transform, return. + fn transform_tick(&self, mut circuit: TickCircuit) -> TickCircuit { + self.apply_tick(&mut circuit); + circuit + } + + /// Take ownership, transform, return. + fn transform_dag(&self, mut circuit: DagCircuit) -> DagCircuit { + self.apply_dag(&mut circuit); + circuit + } +} + +// ============================================================================ +// Free functions: primary user-facing pass API +// ============================================================================ + +/// Lower Clifford-angle rotations to named Clifford gates. +/// +/// RZ(pi/2) -> SZ, RZ(pi) -> Z, RX(pi/2) -> SX, etc. +/// Also decomposes two-qubit rotations: RZZ(pi) -> Z+Z. +pub fn lower_clifford_rotations(circuit: &mut TickCircuit) { + SimplifyRotations.apply_tick(circuit); +} + +/// Insert Idle gates after each two-qubit gate on both of its qubits. +/// +/// Adds Idle(duration) on both qubits of each 2q gate. Models idle noise +/// during 2q gate execution. +pub fn insert_idle_after_two_qubit_gates(circuit: &mut TickCircuit, duration: f64) { + InsertIdleAfterTwoQubitGates(duration).apply_tick(circuit); +} + +/// Remove identity gates (I, Idle, zero-angle rotations). +pub fn remove_identity(circuit: &mut TickCircuit) { + RemoveIdentity.apply_tick(circuit); +} + +/// Cancel adjacent inverse gate pairs (H-H, S-Sdg, T-Tdg, etc.). +pub fn cancel_inverses(circuit: &mut TickCircuit) { + CancelInverses.apply_tick(circuit); } +/// Merge adjacent rotations on the same qubit into a single rotation. +pub fn merge_adjacent_rotations(circuit: &mut TickCircuit) { + MergeAdjacentRotations.apply_tick(circuit); +} + +/// Peephole optimization (rotation merging + Clifford lowering). +pub fn peephole_optimize(circuit: &mut TickCircuit) { + PeepholeOptimize.apply_tick(circuit); +} + +/// Absorb single-qubit basis gates into adjacent preps/measurements. +pub fn absorb_basis_gates(circuit: &mut TickCircuit) { + AbsorbBasisGates.apply_tick(circuit); +} + +/// Compact ticks by ASAP scheduling (merge gates into earlier ticks). +pub fn compact_ticks(circuit: &mut TickCircuit) { + CompactTicks.apply_tick(circuit); +} + +/// Assign `MeasId` to measurement gates that don't have them. +/// +/// Walks the circuit in tick order and assigns sequential `MeasId`s +/// to any MZ/MeasureFree gate with empty `meas_ids`. Existing `MeasId`s +/// are preserved. New IDs continue from the circuit's current counter. +pub fn assign_missing_meas_ids(circuit: &mut TickCircuit) { + AssignMissingMeasIds.apply_tick(circuit); +} + +// ============================================================================ +// Pass trait and pipeline +// ============================================================================ + /// An ordered collection of passes applied sequentially. /// /// `PassPipeline` itself implements [`CircuitPass`], so pipelines can be @@ -122,9 +213,70 @@ impl CircuitPass for PassPipeline { /// | RYY | pi | Y + Y | pub struct SimplifyRotations; +/// Insert Idle gates after each two-qubit gate on both of its qubits. +/// +/// For each tick containing two-qubit gates, adds a new tick immediately +/// after with `Idle(duration)` on each qubit involved in a two-qubit gate. +/// +/// This models the idle noise that qubits experience during two-qubit +/// gate execution. The noise model applies `RZ(p_idle * duration)` when +/// it encounters an Idle gate. +/// +/// The inner value is the idle duration in time units (typically 1.0). +pub struct InsertIdleAfterTwoQubitGates(pub f64); + +impl CircuitPass for InsertIdleAfterTwoQubitGates { + fn apply_tick(&self, circuit: &mut TickCircuit) { + let duration = self.0; + let mut new_ticks = Vec::with_capacity(circuit.ticks().len() * 2); + + // Drain ticks from circuit and rebuild with idle insertions + let old_ticks = circuit.take_ticks(); + + for tick in old_ticks { + let mut idle_qubits: Vec = Vec::new(); + for gate in tick.iter_gate_batches() { + if gate.is_two_qubit() { + for q in &gate.qubits { + if !idle_qubits.contains(q) { + idle_qubits.push(*q); + } + } + } + } + + new_ticks.push(tick); + + if !idle_qubits.is_empty() { + let mut idle_tick = crate::Tick::new(); + for q in idle_qubits { + idle_tick.add_gate(Gate::idle(duration, vec![q])); + } + new_ticks.push(idle_tick); + } + } + + circuit.replace_ticks(new_ticks); + } + + fn apply_dag(&self, _circuit: &mut DagCircuit) { + // DAG doesn't have tick structure — no-op + } +} + /// Apply an in-place simplification to a gate. Returns `true` if the gate was /// simplified (either renamed in place or needs decomposition handling). fn simplify_gate_in_place(gate: &mut Gate) -> bool { + // R1XY has two angles — handle separately + if gate.gate_type == GateType::R1XY && gate.angles.len() == 2 { + if let Some(named) = pecos_core::try_simplify_r1xy(gate.angles[0], gate.angles[1]) { + gate.gate_type = named; + gate.angles.clear(); + return true; + } + return false; + } + if gate.angles.len() != 1 { return false; } @@ -290,6 +442,62 @@ fn peephole_conjugation(middle: &Gate, h_qubit: QubitId) -> Option<(GateType, Ga } } +fn split_batched_tick_commands(circuit: &mut TickCircuit) { + let old_ticks = circuit.take_ticks(); + let mut new_ticks = Vec::with_capacity(old_ticks.len()); + + for old_tick in old_ticks { + let mut new_tick = Tick::new(); + for (key, value) in old_tick.tick_attrs() { + new_tick.set_attr(key, value.clone()); + } + + for batch in old_tick.iter_gate_batches() { + let gate = batch.as_gate(); + let attrs: BTreeMap = batch + .attrs() + .map(|(key, value)| (key.clone(), value.clone())) + .collect(); + + let split_gates: Vec = if batch.gate_count() == 0 { + vec![gate.clone()] + } else { + batch + .iter_gate_instances() + .map(super::tick_circuit::GateInstanceRef::to_gate) + .collect() + }; + + if split_gates.is_empty() { + continue; + } + + if split_gates.len() == 1 { + let new_idx = new_tick + .try_add_gate_preserving_command(split_gates[0].clone()) + .unwrap_or_else(|err| panic!("{err}")); + if !attrs.is_empty() { + new_tick.set_gate_attrs(new_idx, attrs); + } + continue; + } + + for split_gate in split_gates { + let new_idx = new_tick + .try_add_gate_preserving_command(split_gate) + .unwrap_or_else(|err| panic!("{err}")); + if !attrs.is_empty() { + new_tick.set_gate_attrs(new_idx, attrs.clone()); + } + } + } + + new_ticks.push(new_tick); + } + + circuit.replace_ticks(new_ticks); +} + impl CircuitPass for SimplifyRotations { fn apply_tick(&self, circuit: &mut TickCircuit) { for tick in circuit.ticks_mut() { @@ -297,18 +505,18 @@ impl CircuitPass for SimplifyRotations { // We need to know which gate indices to remove and what to add. let mut decompositions: Vec<(usize, GateType)> = Vec::new(); - for (i, gate) in tick.gates().iter().enumerate() { + for gate in tick.iter_gate_batches() { if gate.angles.len() == 1 && let Some(pauli) = pecos_core::half_turn_decomposition(gate.gate_type, gate.angles[0]) { - decompositions.push((i, pauli)); + decompositions.push((gate.batch_index(), pauli)); } } // Process decompositions in reverse order to keep indices valid. for &(idx, pauli) in decompositions.iter().rev() { - let qubits = tick.gates()[idx].qubits.clone(); + let qubits = tick.gate_batches()[idx].qubits.clone(); // Remove the two-qubit gate, add two single-qubit gates. tick.remove_gate(idx); for pair in qubits.chunks(2) { @@ -320,8 +528,11 @@ impl CircuitPass for SimplifyRotations { } // Second pass: in-place simplification of remaining gates. - for gate in tick.gates_mut() { - simplify_gate_in_place(gate); + for gate_idx in 0..tick.len() { + tick.update_gate_batch(gate_idx, |gate| { + simplify_gate_in_place(gate); + }) + .unwrap_or_else(|err| panic!("{err}")); } } } @@ -403,7 +614,7 @@ impl CircuitPass for RemoveIdentity { fn apply_tick(&self, circuit: &mut TickCircuit) { for tick in circuit.ticks_mut() { let to_remove: Vec = tick - .gates() + .gate_batches() .iter() .enumerate() .filter(|(_, g)| is_identity_gate(g)) @@ -452,13 +663,14 @@ impl CircuitPass for CancelInverses { let mut stacks: HashMap> = HashMap::new(); let mut to_remove: Vec<(usize, usize)> = Vec::new(); - for (ti, tick) in circuit.ticks().iter().enumerate() { - for (gi, gate) in tick.gates().iter().enumerate() { + for (ti, tick) in circuit.iter_ticks() { + for gate in tick.iter_gate_batches() { + let gi = gate.batch_index(); let qubits: Vec = gate.qubits.iter().copied().collect(); if let Some((pred_ti, pred_gi)) = check_all_stacks_agree(&stacks, &qubits) { - let pred_gate = &circuit.ticks()[pred_ti].gates()[pred_gi]; - if are_inverses(pred_gate, gate) { + let pred_gate = &circuit.ticks()[pred_ti].gate_batches()[pred_gi]; + if are_inverses(pred_gate, gate.as_gate()) { for &q in &qubits { if let Some(stack) = stacks.get_mut(&q) { stack.pop(); @@ -535,15 +747,16 @@ impl CircuitPass for MergeAdjacentRotations { let mut angle_adjustments: HashMap<(usize, usize), Angle64> = HashMap::new(); let mut to_remove: Vec<(usize, usize)> = Vec::new(); - for (ti, tick) in circuit.ticks().iter().enumerate() { - for (gi, gate) in tick.gates().iter().enumerate() { + for (ti, tick) in circuit.iter_ticks() { + for gate in tick.iter_gate_batches() { + let gi = gate.batch_index(); let qubits: Vec = gate.qubits.iter().copied().collect(); if is_rotation(gate.gate_type) && gate.angles.len() == 1 && let Some((pred_ti, pred_gi)) = check_all_stacks_agree(&stacks, &qubits) { - let pred_gate = &circuit.ticks()[pred_ti].gates()[pred_gi]; + let pred_gate = &circuit.ticks()[pred_ti].gate_batches()[pred_gi]; if pred_gate.gate_type == gate.gate_type && pred_gate.qubits == gate.qubits { *angle_adjustments .entry((pred_ti, pred_gi)) @@ -563,10 +776,11 @@ impl CircuitPass for MergeAdjacentRotations { // Apply angle adjustments to surviving gates. for (&(ti, gi), &delta) in &angle_adjustments { - if let Some(tick) = circuit.get_tick_mut(ti) - && let Some(gate) = tick.gates_mut().get_mut(gi) - { - gate.angles[0] += delta; + if let Some(tick) = circuit.get_tick_mut(ti) { + tick.update_gate_batch(gi, |gate| { + gate.angles[0] += delta; + }) + .unwrap_or_else(|err| panic!("{err}")); } } @@ -638,10 +852,13 @@ pub struct PeepholeOptimize; impl CircuitPass for PeepholeOptimize { fn apply_tick(&self, circuit: &mut TickCircuit) { + split_batched_tick_commands(circuit); + // Build per-qubit timeline: Vec of (tick_idx, gate_idx) in order. let mut timelines: HashMap> = HashMap::new(); - for (ti, tick) in circuit.ticks().iter().enumerate() { - for (gi, gate) in tick.gates().iter().enumerate() { + for (ti, tick) in circuit.iter_ticks() { + for gate in tick.iter_gate_batches() { + let gi = gate.batch_index(); for &q in &gate.qubits { timelines.entry(q).or_default().push((ti, gi)); } @@ -671,9 +888,9 @@ impl CircuitPass for PeepholeOptimize { continue; } - let h1 = &circuit.ticks()[h1_ti].gates()[h1_gi]; - let mid = &circuit.ticks()[mid_ti].gates()[mid_gi]; - let h2 = &circuit.ticks()[h2_ti].gates()[h2_gi]; + let h1 = &circuit.ticks()[h1_ti].gate_batches()[h1_gi]; + let mid = &circuit.ticks()[mid_ti].gate_batches()[mid_gi]; + let h2 = &circuit.ticks()[h2_ti].gate_batches()[h2_gi]; // Both must be single-qubit H on this qubit. if h1.gate_type != GateType::H @@ -698,11 +915,12 @@ impl CircuitPass for PeepholeOptimize { // Apply replacements. for ((ti, gi), new_gt, new_qubits) in &replacements { - if let Some(tick) = circuit.get_tick_mut(*ti) - && let Some(gate) = tick.gates_mut().get_mut(*gi) - { - gate.gate_type = *new_gt; - gate.qubits.clone_from(new_qubits); + if let Some(tick) = circuit.get_tick_mut(*ti) { + tick.update_gate_batch(*gi, |gate| { + gate.gate_type = *new_gt; + gate.qubits.clone_from(new_qubits); + }) + .unwrap_or_else(|err| panic!("{err}")); } } @@ -834,13 +1052,14 @@ impl CircuitPass for AbsorbBasisGates { // Forward scan: absorb Z-diagonal gates after Z-preps. let mut z_eigenstate: HashSet = HashSet::new(); - for (ti, tick) in circuit.ticks().iter().enumerate() { - for (gi, gate) in tick.gates().iter().enumerate() { + for (ti, tick) in circuit.iter_ticks() { + for gate in tick.iter_gate_batches() { + let gi = gate.batch_index(); if is_z_prep(gate.gate_type) { for &q in &gate.qubits { z_eigenstate.insert(q); } - } else if is_z_diagonal(gate) + } else if is_z_diagonal(gate.as_gate()) && gate.qubits.iter().all(|q| z_eigenstate.contains(q)) { to_remove.push((ti, gi)); @@ -855,7 +1074,7 @@ impl CircuitPass for AbsorbBasisGates { // Backward scan: absorb Z-diagonal gates before Z-measures. let mut before_z_measure: HashSet = HashSet::new(); for (ti, tick) in circuit.ticks().iter().enumerate().rev() { - for (gi, gate) in tick.gates().iter().enumerate().rev() { + for (gi, gate) in tick.gate_batches().iter().enumerate().rev() { if is_z_measure(gate.gate_type) { for &q in &gate.qubits { before_z_measure.insert(q); @@ -965,12 +1184,10 @@ impl CircuitPass for CompactTicks { // Collect every gate together with its per-gate attributes. let mut entries: Vec<(Gate, BTreeMap)> = Vec::new(); for tick in circuit.ticks() { - for (gi, gate) in tick.gates().iter().enumerate() { - let attrs: BTreeMap = tick - .gate_attrs(gi) - .map(|(k, v)| (k.clone(), v.clone())) - .collect(); - entries.push((gate.clone(), attrs)); + for gate in tick.iter_gate_batches() { + let attrs: BTreeMap = + gate.attrs().map(|(k, v)| (k.clone(), v.clone())).collect(); + entries.push((gate.as_gate().clone(), attrs)); } } @@ -1032,10 +1249,50 @@ impl CircuitPass for CompactTicks { } } +/// Assign [`MeasId`](pecos_core::MeasId) to measurement gates that don't have them. +/// +/// Walks the circuit in tick order and assigns sequential IDs to any +/// measurement gate with empty `meas_ids`. Existing IDs are preserved. +/// New IDs continue from the circuit's current measurement counter. +/// +/// Use this on circuits from external sources (QIS trace, Stim import) +/// that don't assign `MeasId` during construction. +pub struct AssignMissingMeasIds; + +impl CircuitPass for AssignMissingMeasIds { + fn apply_tick(&self, circuit: &mut TickCircuit) { + let mut next_id = circuit.num_measurements(); + for tick in circuit.ticks_mut() { + for gate_idx in 0..tick.len() { + tick.update_gate_batch(gate_idx, |gate| { + let is_measurement = + matches!(gate.gate_type, GateType::MZ | GateType::MeasureFree); + if is_measurement && gate.meas_ids.is_empty() { + for _ in &gate.qubits { + gate.meas_ids.push(pecos_core::MeasId(next_id)); + next_id += 1; + } + } + }) + .unwrap_or_else(|err| panic!("{err}")); + } + } + let added = next_id - circuit.num_measurements(); + if added > 0 { + circuit.advance_meas_counter(added); + } + } + + fn apply_dag(&self, _circuit: &mut DagCircuit) { + // No-op: DagCircuit gates are accessed differently. + } +} + #[cfg(test)] #[allow(clippy::cast_precision_loss)] mod tests { use super::*; + use pecos_core::MeasId; // ==================== simplify_rotation unit tests ==================== @@ -1202,7 +1459,7 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().rz(Angle64::QUARTER_TURN, &[0]); SimplifyRotations.apply_tick(&mut tc); - let gate = &tc.ticks()[0].gates()[0]; + let gate = &tc.ticks()[0].gate_batches()[0]; assert_eq!(gate.gate_type, GateType::SZ); assert!(gate.angles.is_empty()); } @@ -1212,7 +1469,7 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().rz(Angle64::HALF_TURN, &[0]); SimplifyRotations.apply_tick(&mut tc); - let gate = &tc.ticks()[0].gates()[0]; + let gate = &tc.ticks()[0].gate_batches()[0]; assert_eq!(gate.gate_type, GateType::Z); assert!(gate.angles.is_empty()); } @@ -1222,7 +1479,7 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().rx(Angle64::QUARTER_TURN, &[0]); SimplifyRotations.apply_tick(&mut tc); - let gate = &tc.ticks()[0].gates()[0]; + let gate = &tc.ticks()[0].gate_batches()[0]; assert_eq!(gate.gate_type, GateType::SX); assert!(gate.angles.is_empty()); } @@ -1232,7 +1489,7 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().ry(Angle64::HALF_TURN, &[0]); SimplifyRotations.apply_tick(&mut tc); - let gate = &tc.ticks()[0].gates()[0]; + let gate = &tc.ticks()[0].gate_batches()[0]; assert_eq!(gate.gate_type, GateType::Y); assert!(gate.angles.is_empty()); } @@ -1242,7 +1499,7 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().rzz(Angle64::QUARTER_TURN, &[(0, 1)]); SimplifyRotations.apply_tick(&mut tc); - let gate = &tc.ticks()[0].gates()[0]; + let gate = &tc.ticks()[0].gate_batches()[0]; assert_eq!(gate.gate_type, GateType::SZZ); assert!(gate.angles.is_empty()); } @@ -1252,10 +1509,14 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().rzz(Angle64::HALF_TURN, &[(0, 1)]); SimplifyRotations.apply_tick(&mut tc); - let gates = tc.ticks()[0].gates(); - assert_eq!(gates.len(), 2); + let gates = tc.ticks()[0].gate_batches(); + assert_eq!(gates.len(), 1); assert_eq!(gates[0].gate_type, GateType::Z); - assert_eq!(gates[1].gate_type, GateType::Z); + assert_eq!( + gates[0].qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + assert_eq!(gates[0].num_gates(), 2); } #[test] @@ -1263,10 +1524,14 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().rxx(Angle64::HALF_TURN, &[(0, 1)]); SimplifyRotations.apply_tick(&mut tc); - let gates = tc.ticks()[0].gates(); - assert_eq!(gates.len(), 2); + let gates = tc.ticks()[0].gate_batches(); + assert_eq!(gates.len(), 1); assert_eq!(gates[0].gate_type, GateType::X); - assert_eq!(gates[1].gate_type, GateType::X); + assert_eq!( + gates[0].qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + assert_eq!(gates[0].num_gates(), 2); } #[test] @@ -1274,7 +1539,7 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().rz(Angle64::from_turn_ratio(1, 6), &[0]); SimplifyRotations.apply_tick(&mut tc); - let gate = &tc.ticks()[0].gates()[0]; + let gate = &tc.ticks()[0].gate_batches()[0]; assert_eq!(gate.gate_type, GateType::RZ); assert_eq!(gate.angles.len(), 1); } @@ -1284,7 +1549,7 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().h(&[0]); SimplifyRotations.apply_tick(&mut tc); - let gate = &tc.ticks()[0].gates()[0]; + let gate = &tc.ticks()[0].gate_batches()[0]; assert_eq!(gate.gate_type, GateType::H); } @@ -1293,7 +1558,7 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().rz(Angle64::from_turn_ratio(1, 8), &[0]); SimplifyRotations.apply_tick(&mut tc); - let gate = &tc.ticks()[0].gates()[0]; + let gate = &tc.ticks()[0].gate_batches()[0]; assert_eq!(gate.gate_type, GateType::T); assert!(gate.angles.is_empty()); } @@ -1508,13 +1773,12 @@ mod tests { let mut tick_ops: Vec = Vec::new(); for tick in tc.ticks() { - let gates = tick.gates(); - if gates.is_empty() { + if tick.is_empty() { continue; } let mut gate_ops: Vec = Vec::new(); - for gate in gates { - let op = gate_to_unitary(gate)?; + for gate in tick.iter_gate_batches() { + let op = gate_to_unitary(gate.as_gate())?; gate_ops.push(op); } // Tensor all gates in this tick (they act on disjoint qubits). @@ -1535,7 +1799,19 @@ mod tests { /// Convert a single `Gate` to an `UnitaryRep`. fn gate_to_unitary(gate: &pecos_core::Gate) -> Option { - let q0 = gate.qubits.first().copied()?; + let arity = gate.gate_type.quantum_arity(); + let mut ops = Vec::new(); + for qubits in gate.qubits.chunks(arity) { + if qubits.len() != arity { + return None; + } + ops.push(gate_instance_to_unitary(gate, qubits)?); + } + ops.into_iter().reduce(|a, b| a & b) + } + + fn gate_instance_to_unitary(gate: &pecos_core::Gate, qubits: &[QubitId]) -> Option { + let q0 = qubits.first().copied()?; match gate.gate_type { GateType::H => Some(unitary_rep::H(q0)), GateType::X => Some(unitary_rep::X(q0)), @@ -1562,38 +1838,38 @@ mod tests { Some(unitary_rep::RZ(angle, q0)) } GateType::CX => { - let q1 = gate.qubits.get(1).copied()?; + let q1 = qubits.get(1).copied()?; Some(unitary_rep::CX(q0, q1)) } GateType::CY => { - let q1 = gate.qubits.get(1).copied()?; + let q1 = qubits.get(1).copied()?; Some(unitary_rep::CY(q0, q1)) } GateType::CZ => { - let q1 = gate.qubits.get(1).copied()?; + let q1 = qubits.get(1).copied()?; Some(unitary_rep::CZ(q0, q1)) } GateType::RXX => { - let q1 = gate.qubits.get(1).copied()?; + let q1 = qubits.get(1).copied()?; let angle = *gate.angles.first()?; Some(unitary_rep::RXX(angle, q0, q1)) } GateType::RYY => { - let q1 = gate.qubits.get(1).copied()?; + let q1 = qubits.get(1).copied()?; let angle = *gate.angles.first()?; Some(unitary_rep::RYY(angle, q0, q1)) } GateType::RZZ => { - let q1 = gate.qubits.get(1).copied()?; + let q1 = qubits.get(1).copied()?; let angle = *gate.angles.first()?; Some(unitary_rep::RZZ(angle, q0, q1)) } GateType::SZZ => { - let q1 = gate.qubits.get(1).copied()?; + let q1 = qubits.get(1).copied()?; Some(unitary_rep::SZZ(q0, q1)) } GateType::SZZdg => { - let q1 = gate.qubits.get(1).copied()?; + let q1 = qubits.get(1).copied()?; Some(unitary_rep::SZZ(q0, q1).dg()) } GateType::I | GateType::Idle => Some(unitary_rep::I(q0)), @@ -1890,7 +2166,7 @@ mod tests { tc.tick(); tc.ticks_mut()[0].add_gate(Gate::i(&[0])); RemoveIdentity.apply_tick(&mut tc); - assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[0].gate_batches().is_empty()); } #[test] @@ -1898,7 +2174,7 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().rz(Angle64::ZERO, &[0]); RemoveIdentity.apply_tick(&mut tc); - assert!(tc.ticks()[0].gates().is_empty()); + assert!(tc.ticks()[0].gate_batches().is_empty()); } #[test] @@ -1906,8 +2182,8 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().rz(Angle64::QUARTER_TURN, &[0]); RemoveIdentity.apply_tick(&mut tc); - assert_eq!(tc.ticks()[0].gates().len(), 1); - assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::RZ); + assert_eq!(tc.ticks()[0].gate_batches().len(), 1); + assert_eq!(tc.ticks()[0].gate_batches()[0].gate_type, GateType::RZ); } #[test] @@ -1916,8 +2192,8 @@ mod tests { tc.tick().h(&[0]); tc.ticks_mut()[0].add_gate(Gate::i(&[1])); RemoveIdentity.apply_tick(&mut tc); - assert_eq!(tc.ticks()[0].gates().len(), 1); - assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::H); + assert_eq!(tc.ticks()[0].gate_batches().len(), 1); + assert_eq!(tc.ticks()[0].gate_batches()[0].gate_type, GateType::H); } // ==================== RemoveIdentity DAG tests ==================== @@ -1966,8 +2242,8 @@ mod tests { tc.tick().h(&[0]); tc.tick().h(&[0]); CancelInverses.apply_tick(&mut tc); - assert!(tc.ticks()[0].gates().is_empty()); - assert!(tc.ticks()[1].gates().is_empty()); + assert!(tc.ticks()[0].gate_batches().is_empty()); + assert!(tc.ticks()[1].gate_batches().is_empty()); } #[test] @@ -1976,8 +2252,8 @@ mod tests { tc.tick().x(&[0]); tc.tick().x(&[0]); CancelInverses.apply_tick(&mut tc); - assert!(tc.ticks()[0].gates().is_empty()); - assert!(tc.ticks()[1].gates().is_empty()); + assert!(tc.ticks()[0].gate_batches().is_empty()); + assert!(tc.ticks()[1].gate_batches().is_empty()); } #[test] @@ -1987,8 +2263,8 @@ mod tests { tc.tick(); tc.ticks_mut()[1].add_gate(Gate::sxdg(&[0])); CancelInverses.apply_tick(&mut tc); - assert!(tc.ticks()[0].gates().is_empty()); - assert!(tc.ticks()[1].gates().is_empty()); + assert!(tc.ticks()[0].gate_batches().is_empty()); + assert!(tc.ticks()[1].gate_batches().is_empty()); } #[test] @@ -1998,8 +2274,8 @@ mod tests { tc.tick(); tc.ticks_mut()[1].add_gate(Gate::tdg(&[0])); CancelInverses.apply_tick(&mut tc); - assert!(tc.ticks()[0].gates().is_empty()); - assert!(tc.ticks()[1].gates().is_empty()); + assert!(tc.ticks()[0].gate_batches().is_empty()); + assert!(tc.ticks()[1].gate_batches().is_empty()); } #[test] @@ -2008,8 +2284,8 @@ mod tests { tc.tick().cx(&[(0, 1)]); tc.tick().cx(&[(0, 1)]); CancelInverses.apply_tick(&mut tc); - assert!(tc.ticks()[0].gates().is_empty()); - assert!(tc.ticks()[1].gates().is_empty()); + assert!(tc.ticks()[0].gate_batches().is_empty()); + assert!(tc.ticks()[1].gate_batches().is_empty()); } #[test] @@ -2019,8 +2295,8 @@ mod tests { tc.tick().rz(angle, &[0]); tc.tick().rz(-angle, &[0]); CancelInverses.apply_tick(&mut tc); - assert!(tc.ticks()[0].gates().is_empty()); - assert!(tc.ticks()[1].gates().is_empty()); + assert!(tc.ticks()[0].gate_batches().is_empty()); + assert!(tc.ticks()[1].gate_batches().is_empty()); } #[test] @@ -2034,7 +2310,7 @@ mod tests { tc.tick().h(&[0]); CancelInverses.apply_tick(&mut tc); for tick in tc.ticks() { - assert!(tick.gates().is_empty()); + assert!(tick.gate_batches().is_empty()); } } @@ -2046,9 +2322,9 @@ mod tests { tc.tick().x(&[0]); tc.tick().h(&[0]); CancelInverses.apply_tick(&mut tc); - assert_eq!(tc.ticks()[0].gates().len(), 1); - assert_eq!(tc.ticks()[1].gates().len(), 1); - assert_eq!(tc.ticks()[2].gates().len(), 1); + assert_eq!(tc.ticks()[0].gate_batches().len(), 1); + assert_eq!(tc.ticks()[1].gate_batches().len(), 1); + assert_eq!(tc.ticks()[2].gate_batches().len(), 1); } #[test] @@ -2057,8 +2333,8 @@ mod tests { tc.tick().h(&[0]); tc.tick().h(&[1]); CancelInverses.apply_tick(&mut tc); - assert_eq!(tc.ticks()[0].gates().len(), 1); - assert_eq!(tc.ticks()[1].gates().len(), 1); + assert_eq!(tc.ticks()[0].gate_batches().len(), 1); + assert_eq!(tc.ticks()[1].gate_batches().len(), 1); } // ==================== CancelInverses DAG tests ==================== @@ -2107,7 +2383,7 @@ mod tests { let gate = tc .ticks() .iter() - .flat_map(super::super::tick_circuit::Tick::gates) + .flat_map(super::super::tick_circuit::Tick::gate_batches) .next() .unwrap(); assert_eq!(gate.gate_type, GateType::RZ); @@ -2124,7 +2400,7 @@ mod tests { let gate = tc .ticks() .iter() - .flat_map(super::super::tick_circuit::Tick::gates) + .flat_map(super::super::tick_circuit::Tick::gate_batches) .next() .unwrap(); assert_eq!(gate.gate_type, GateType::RZ); @@ -2140,7 +2416,7 @@ mod tests { let gate = tc .ticks() .iter() - .flat_map(super::super::tick_circuit::Tick::gates) + .flat_map(super::super::tick_circuit::Tick::gate_batches) .next() .unwrap(); assert_eq!(gate.gate_type, GateType::RZ); @@ -2156,7 +2432,7 @@ mod tests { let gate = tc .ticks() .iter() - .flat_map(super::super::tick_circuit::Tick::gates) + .flat_map(super::super::tick_circuit::Tick::gate_batches) .next() .unwrap(); assert_eq!(gate.gate_type, GateType::RZZ); @@ -2298,20 +2574,20 @@ mod tests { // ==================== Pass effectiveness analysis ==================== - /// Count total gates across all ticks. - fn count_gates(tc: &TickCircuit) -> usize { - tc.ticks().iter().map(|t| t.gates().len()).sum() + /// Count stored gate batches across all ticks. + fn count_gate_batches(tc: &TickCircuit) -> usize { + tc.ticks().iter().map(|t| t.gate_batches().len()).sum() } - /// Apply the full pipeline and return (before, after) gate counts. + /// Apply the full pipeline and return (before, after) gate-batch counts. fn pipeline_stats(tc: &mut TickCircuit) -> (usize, usize) { - let before = count_gates(tc); + let before = count_gate_batches(tc); MergeAdjacentRotations.apply_tick(tc); RemoveIdentity.apply_tick(tc); SimplifyRotations.apply_tick(tc); CancelInverses.apply_tick(tc); PeepholeOptimize.apply_tick(tc); - let after = count_gates(tc); + let after = count_gate_batches(tc); (before, after) } @@ -2348,17 +2624,6 @@ mod tests { // -- Circuit 3: Inverse cancellation (from circuit composition) -- let mut c3 = TickCircuit::new(); - // Subcircuit A applies some basis change - c3.tick().h(&[0, 1]); - c3.tick().sx(&[0]).t(&[1]); - c3.tick().cx(&[(0, 1)]); - // Subcircuit B undoes the basis change then does something else - c3.tick().cx(&[(0, 1)]); - c3.ticks_mut()[3].add_gate(Gate::sxdg(&[0])); - c3.ticks_mut()[3].add_gate(Gate::tdg(&[1])); - // Wait, this won't cancel because CX is between SX and SXdg on different ticks. - // Let me restructure: undo in reverse order - let mut c3 = TickCircuit::new(); c3.tick().h(&[0, 1]); c3.tick().t(&[0]).sx(&[1]); c3.tick().cx(&[(0, 1)]); @@ -2509,7 +2774,7 @@ mod tests { let gates: Vec<&Gate> = tc .ticks() .iter() - .flat_map(super::super::tick_circuit::Tick::gates) + .flat_map(super::super::tick_circuit::Tick::gate_batches) .collect(); assert_eq!(gates.len(), 1); assert_eq!(gates[0].gate_type, GateType::CZ); @@ -2527,7 +2792,7 @@ mod tests { let gates: Vec<&Gate> = tc .ticks() .iter() - .flat_map(super::super::tick_circuit::Tick::gates) + .flat_map(super::super::tick_circuit::Tick::gate_batches) .collect(); assert_eq!(gates.len(), 1); assert_eq!(gates[0].gate_type, GateType::CX); @@ -2546,7 +2811,7 @@ mod tests { let gates: Vec<&Gate> = tc .ticks() .iter() - .flat_map(super::super::tick_circuit::Tick::gates) + .flat_map(super::super::tick_circuit::Tick::gate_batches) .collect(); assert_eq!(gates.len(), 3); // unchanged } @@ -2564,7 +2829,7 @@ mod tests { let gates: Vec<&Gate> = tc .ticks() .iter() - .flat_map(super::super::tick_circuit::Tick::gates) + .flat_map(super::super::tick_circuit::Tick::gate_batches) .collect(); assert_eq!(gates.len(), 3); // X, CZ, Z assert_eq!(gates[0].gate_type, GateType::X); @@ -2572,6 +2837,126 @@ mod tests { assert_eq!(gates[2].gate_type, GateType::Z); } + #[test] + fn peephole_tick_preserves_metadata_when_splitting_batched_commands() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[1]); + tc.tick() + .cx(&[(0, 1), (2, 3)]) + .meta("calibration", Attribute::String("entangler".into())); + tc.tick().h(&[1]); + + PeepholeOptimize.apply_tick(&mut tc); + + let middle = tc + .ticks() + .iter() + .find(|tick| !tick.gate_batches().is_empty()) + .expect("peephole result should keep the middle tick"); + assert_eq!(middle.len(), 2); + assert_eq!(middle.gate_count(), 2); + + let mut saw_rewritten = false; + let mut saw_untouched = false; + for gate in middle.iter_gate_batches() { + assert_eq!( + gate.get_attr("calibration"), + Some(&Attribute::String("entangler".into())) + ); + match gate.gate_type { + GateType::CZ => { + saw_rewritten = true; + assert_eq!( + gate.qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + } + GateType::CX => { + saw_untouched = true; + assert_eq!( + gate.qubits.as_slice(), + &[QubitId::from(2), QubitId::from(3)] + ); + } + other => panic!("unexpected gate after peephole optimization: {other:?}"), + } + } + assert!(saw_rewritten); + assert!(saw_untouched); + } + + #[test] + fn split_batched_tick_commands_preserves_payloads_attrs_and_counters() { + let mut tc = TickCircuit::new(); + let initial_refs = tc.tick().mz(&[0, 1]); + assert_eq!(initial_refs[0].record_idx, 0); + assert_eq!(initial_refs[1].record_idx, 1); + tc.get_tick_mut(0).unwrap().set_gate_attr( + 0, + "role", + Attribute::String("measurement".into()), + ); + tc.tick() + .cx(&[(2, 3), (4, 5)]) + .meta("role", Attribute::String("entangler".into())); + + split_batched_tick_commands(&mut tc); + + assert_eq!(tc.num_ticks(), 2); + assert_eq!(tc.next_tick_index(), 2); + assert_eq!(tc.num_measurements(), 2); + + let meas_tick = tc.get_tick(0).unwrap(); + assert_eq!(meas_tick.len(), 2); + assert_eq!(meas_tick.gate_count(), 2); + assert_eq!( + meas_tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0)] + ); + assert_eq!( + meas_tick.gate_batches()[0].meas_ids.as_slice(), + &[MeasId(0)] + ); + assert_eq!( + meas_tick.gate_batches()[1].qubits.as_slice(), + &[QubitId::from(1)] + ); + assert_eq!( + meas_tick.gate_batches()[1].meas_ids.as_slice(), + &[MeasId(1)] + ); + for batch in meas_tick.iter_gate_batches() { + assert_eq!( + batch.get_attr("role"), + Some(&Attribute::String("measurement".into())) + ); + } + + let entangler_tick = tc.get_tick(1).unwrap(); + assert_eq!(entangler_tick.len(), 2); + assert_eq!(entangler_tick.gate_count(), 2); + assert_eq!( + entangler_tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(2), QubitId::from(3)] + ); + assert_eq!( + entangler_tick.gate_batches()[1].qubits.as_slice(), + &[QubitId::from(4), QubitId::from(5)] + ); + for batch in entangler_tick.iter_gate_batches() { + assert_eq!( + batch.get_attr("role"), + Some(&Attribute::String("entangler".into())) + ); + } + + let later_refs = tc.tick().mz(&[6]); + assert_eq!(later_refs[0].record_idx, 2); + assert_eq!(later_refs[0].meas_id, MeasId(2)); + assert_eq!(tc.next_tick_index(), 3); + assert_eq!(tc.num_measurements(), 3); + } + #[test] fn peephole_tick_multiple_patterns() { // Two independent H-CX-H patterns @@ -2583,7 +2968,7 @@ mod tests { let gates: Vec<&Gate> = tc .ticks() .iter() - .flat_map(super::super::tick_circuit::Tick::gates) + .flat_map(super::super::tick_circuit::Tick::gate_batches) .collect(); assert_eq!(gates.len(), 2); assert!(gates.iter().all(|g| g.gate_type == GateType::CZ)); @@ -2904,8 +3289,8 @@ mod tests { .then(MergeAdjacentRotations) .then(SimplifyRotations); pipeline.apply_tick(&mut tc); - assert_eq!(count_gates(&tc), 1); - assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::SZ); + assert_eq!(count_gate_batches(&tc), 1); + assert_eq!(tc.ticks()[0].gate_batches()[0].gate_type, GateType::SZ); } #[test] @@ -2929,7 +3314,7 @@ mod tests { .then(CancelInverses); pipeline.apply_tick(&mut tc); // PZ stays, T and both RZs absorbed (after PZ), H+H cancelled, MZ stays - assert_eq!(count_gates(&tc), 2); // PZ + MZ + assert_eq!(count_gate_batches(&tc), 2); // PZ + MZ } #[test] @@ -2953,7 +3338,7 @@ mod tests { let mut tc = TickCircuit::new(); tc.tick().h(&[0]); pipeline.apply_tick(&mut tc); - assert_eq!(count_gates(&tc), 1); + assert_eq!(count_gate_batches(&tc), 1); } // ==================== CompactTicks tests ==================== @@ -2978,8 +3363,8 @@ mod tests { tc.tick().x(&[0]); CompactTicks.apply_tick(&mut tc); assert_eq!(tc.num_ticks(), 2); - assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::H); - assert_eq!(tc.ticks()[1].gates()[0].gate_type, GateType::X); + assert_eq!(tc.ticks()[0].gate_batches()[0].gate_type, GateType::H); + assert_eq!(tc.ticks()[1].gate_batches()[0].gate_type, GateType::X); } #[test] @@ -2994,7 +3379,7 @@ mod tests { assert_eq!(tc.num_ticks(), 3); CompactTicks.apply_tick(&mut tc); assert_eq!(tc.num_ticks(), 1); - assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::X); + assert_eq!(tc.ticks()[0].gate_batches()[0].gate_type, GateType::X); } #[test] @@ -3042,9 +3427,9 @@ mod tests { tc.tick().sz(&[0]); CompactTicks.apply_tick(&mut tc); assert_eq!(tc.num_ticks(), 3); // all same qubit, no compaction - assert_eq!(tc.ticks()[0].gates()[0].gate_type, GateType::H); - assert_eq!(tc.ticks()[1].gates()[0].gate_type, GateType::T); - assert_eq!(tc.ticks()[2].gates()[0].gate_type, GateType::SZ); + assert_eq!(tc.ticks()[0].gate_batches()[0].gate_type, GateType::H); + assert_eq!(tc.ticks()[1].gate_batches()[0].gate_type, GateType::T); + assert_eq!(tc.ticks()[2].gate_batches()[0].gate_type, GateType::SZ); } #[test] @@ -3065,6 +3450,6 @@ mod tests { // After absorb+cancel: PZ(0,1), X(0), MZ(0,1) // X(0) can't merge with PZ (qubit 0 busy) or MZ (qubit 0 busy). assert_eq!(tc.num_ticks(), 3); - assert_eq!(count_gates(&tc), 3); + assert_eq!(count_gate_batches(&tc), 3); } } diff --git a/crates/pecos-quantum/src/pauli_group.rs b/crates/pecos-quantum/src/pauli_group.rs index bd96fb70f..e3e471f51 100644 --- a/crates/pecos-quantum/src/pauli_group.rs +++ b/crates/pecos-quantum/src/pauli_group.rs @@ -39,7 +39,7 @@ //! //! ``` //! use pecos_quantum::PauliGroup; -//! use pecos_core::pauli::constructors::*; +//! use pecos_core::pauli::*; //! use pecos_core::pauli::algebra::i; //! //! // Generators with imaginary phases are allowed @@ -105,7 +105,7 @@ fn generator_order(phase: QuarterPhase) -> u32 { /// /// ``` /// use pecos_quantum::PauliGroup; -/// use pecos_core::pauli::constructors::*; +/// use pecos_core::pauli::*; /// use pecos_core::pauli::algebra::i; /// /// // A group with an imaginary-phase generator @@ -265,15 +265,15 @@ impl PauliGroup { for (row_idx, generator) in self.inner.paulis().iter().enumerate() { for q in generator.x_positions() { if q < n { - mat.rows[row_idx][q] = 1; + mat.set(row_idx, q, 1); } } for q in generator.z_positions() { if q < n { - mat.rows[row_idx][n + q] = 1; + mat.set(row_idx, n + q, 1); } } - mat.rows[row_idx][2 * n + row_idx] = 1; + mat.set(row_idx, 2 * n + row_idx, 1); } let (reduced, pivots) = mat.row_reduce(); @@ -295,7 +295,7 @@ impl PauliGroup { for (row_idx, &pivot_col) in pivots.iter().enumerate() { if target[pivot_col] == 1 { for (col, t) in target.iter_mut().enumerate() { - *t ^= reduced.rows[row_idx][col]; + *t ^= reduced.get(row_idx, col); } } } @@ -453,9 +453,11 @@ impl PauliGroup { self.inner.to_symplectic_matrix() } - /// Returns the commutation matrix (always all-true for a valid group). + /// Returns the pairwise anticommutation matrix. + /// + /// This is always all-zero for a valid commuting group. #[must_use] - pub fn commutation_matrix(&self) -> Vec> { + pub fn commutation_matrix(&self) -> F2Matrix { self.inner.commutation_matrix() } @@ -682,7 +684,7 @@ impl fmt::Display for PauliGroup { mod tests { use super::*; use pecos_core::pauli::algebra::i; - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; // --- Construction and basic properties --- @@ -1164,14 +1166,15 @@ mod tests { // --- commutation_matrix for valid group --- #[test] - fn commutation_matrix_all_true() { + fn commutation_matrix_all_zero() { let group = PauliGroup::new(vec![X(0), Z(1), X(2)]).unwrap(); let mat = group.commutation_matrix(); - for row in &mat { - for &val in row { - assert!( - val, - "commutation matrix should be all-true for abelian group" + for row in 0..mat.num_rows() { + for col in 0..mat.num_cols() { + assert_eq!( + mat.get(row, col), + 0, + "anticommutation matrix should be all-zero for abelian group" ); } } diff --git a/crates/pecos-quantum/src/pauli_sequence.rs b/crates/pecos-quantum/src/pauli_sequence.rs index 0c20c31be..355d5fe87 100644 --- a/crates/pecos-quantum/src/pauli_sequence.rs +++ b/crates/pecos-quantum/src/pauli_sequence.rs @@ -30,7 +30,7 @@ //! //! ``` //! use pecos_quantum::PauliSequence; -//! use pecos_core::pauli::constructors::*; +//! use pecos_core::pauli::*; //! //! let paulis = PauliSequence::new(vec![ //! Zs(&[0, 1]), @@ -50,30 +50,45 @@ use pecos_core::{ParsePauliStringError, PauliOperator, PauliString}; use std::fmt; use std::str::FromStr; -/// A binary matrix over GF(2), represented row-major. +/// A binary matrix over GF(2), represented row-major as packed `u64` words. /// /// Each row is a 2n-bit vector representing a Pauli string in the binary /// symplectic representation: `(x_0, ..., x_{n-1} | z_0, ..., z_{n-1})` /// where `x_q = 1` if qubit q has X or Y, and `z_q = 1` if qubit q has Z or Y. -/// -// TODO: Each bit is stored as a full `u8`. For large codes, consider packing -// into `u64` words or using a bitvec for 8x memory reduction. #[derive(Debug, Clone, PartialEq, Eq)] pub struct F2Matrix { - pub(crate) rows: Vec>, + rows: Vec>, num_cols: usize, } impl F2Matrix { + const WORD_BITS: usize = u64::BITS as usize; + /// Creates a new F2 matrix with the given dimensions, initialized to zero. #[must_use] pub fn zeros(num_rows: usize, num_cols: usize) -> Self { Self { - rows: vec![vec![0; num_cols]; num_rows], + rows: vec![vec![0; Self::num_words(num_cols)]; num_rows], num_cols, } } + /// Creates an F2 matrix from dense 0/1 rows. + /// + /// # Panics + /// + /// Panics if the rows do not all have the same length, or if any entry is + /// not 0 or 1. + #[must_use] + pub fn from_rows(rows: Vec>) -> Self { + let num_cols = rows.first().map_or(0, Vec::len); + let mut mat = Self::zeros(rows.len(), num_cols); + for (i, row) in rows.into_iter().enumerate() { + mat.set_row(i, &row); + } + mat + } + /// Returns the number of rows. #[must_use] pub fn num_rows(&self) -> usize { @@ -88,32 +103,98 @@ impl F2Matrix { /// Returns a reference to the rows. #[must_use] - pub fn rows(&self) -> &[Vec] { - &self.rows + pub fn rows(&self) -> Vec> { + (0..self.num_rows()).map(|i| self.row(i)).collect() + } + + /// Returns a dense copy of a specific row. + #[must_use] + pub fn row(&self, i: usize) -> Vec { + (0..self.num_cols).map(|col| self.get(i, col)).collect() } - /// Returns a reference to a specific row. + /// Returns the packed words for a row. #[must_use] - pub fn row(&self, i: usize) -> &[u8] { + pub(crate) fn row_words(&self, i: usize) -> &[u64] { &self.rows[i] } - /// Returns a mutable reference to a specific row. - pub fn row_mut(&mut self, i: usize) -> &mut Vec { - &mut self.rows[i] + /// Returns a single matrix entry. + /// + /// # Panics + /// + /// Panics if `row` or `col` is out of bounds. + #[must_use] + pub fn get(&self, row: usize, col: usize) -> u8 { + assert!(col < self.num_cols); + let (word, mask) = Self::word_mask(col); + u8::from((self.rows[row][word] & mask) != 0) + } + + /// Sets a single matrix entry. + /// + /// # Panics + /// + /// Panics if `row` or `col` is out of bounds, or if `value` is not 0 or 1. + pub fn set(&mut self, row: usize, col: usize, value: u8) { + assert!(col < self.num_cols); + assert!(value <= 1, "F2Matrix entries must be 0 or 1"); + let (word, mask) = Self::word_mask(col); + if value == 0 { + self.rows[row][word] &= !mask; + } else { + self.rows[row][word] |= mask; + } + } + + /// Replaces one row from a dense 0/1 slice. + /// + /// # Panics + /// + /// Panics if the row length does not match `num_cols`, or if any entry is + /// not 0 or 1. + pub fn set_row(&mut self, row: usize, bits: &[u8]) { + assert_eq!(bits.len(), self.num_cols); + self.rows[row].fill(0); + for (col, &bit) in bits.iter().enumerate() { + if bit != 0 { + assert_eq!(bit, 1, "F2Matrix entries must be 0 or 1"); + self.set(row, col, 1); + } + } } /// Checks if a row is all zeros. #[must_use] pub fn is_zero_row(&self, i: usize) -> bool { - self.rows[i].iter().all(|&b| b == 0) + self.rows[i].iter().all(|&word| word == 0) } /// XORs row `src` into row `dst` (row[dst] ^= row[src]). fn xor_row(&mut self, dst: usize, src: usize) { - // Avoid borrow issues by doing index operations - for col in 0..self.num_cols { - self.rows[dst][col] ^= self.rows[src][col]; + if dst == src { + self.rows[dst].fill(0); + return; + } + let src_row = self.rows[src].clone(); + for (dst_word, src_word) in self.rows[dst].iter_mut().zip(src_row) { + *dst_word ^= src_word; + } + self.clear_unused_bits(dst); + } + + fn xor_row_into_dense(&self, row: usize, dense: &mut [u8]) { + assert_eq!(dense.len(), self.num_cols); + for word_idx in 0..self.rows[row].len() { + let mut word = self.rows[row][word_idx]; + while word != 0 { + let bit = word.trailing_zeros() as usize; + let col = word_idx * Self::WORD_BITS + bit; + if col < self.num_cols { + dense[col] ^= 1; + } + word &= word - 1; + } } } @@ -122,6 +203,40 @@ impl F2Matrix { self.rows.swap(a, b); } + fn num_words(num_cols: usize) -> usize { + num_cols.div_ceil(Self::WORD_BITS) + } + + fn word_mask(col: usize) -> (usize, u64) { + let word = col / Self::WORD_BITS; + let bit = col % Self::WORD_BITS; + (word, 1u64 << bit) + } + + fn last_word_mask(&self) -> u64 { + let rem = self.num_cols % Self::WORD_BITS; + if rem == 0 { + u64::MAX + } else { + (1u64 << rem) - 1 + } + } + + fn clear_unused_bits(&mut self, row: usize) { + if self.num_cols == 0 { + return; + } + let mask = self.last_word_mask(); + if let Some(last) = self.rows[row].last_mut() { + *last &= mask; + } + } + + fn row_has_bit(&self, row: usize, col: usize) -> bool { + let (word, mask) = Self::word_mask(col); + (self.rows[row][word] & mask) != 0 + } + /// Performs Gaussian elimination over GF(2), returning the row echelon form /// and the pivot column positions. /// @@ -137,7 +252,7 @@ impl F2Matrix { // Find a row with a 1 in this column at or below pivot_row let mut found = None; for row in pivot_row..mat.num_rows() { - if mat.rows[row][col] == 1 { + if mat.row_has_bit(row, col) { found = Some(row); break; } @@ -152,7 +267,7 @@ impl F2Matrix { // Eliminate all other rows with a 1 in this column for row in 0..mat.num_rows() { - if row != pivot_row && mat.rows[row][col] == 1 { + if row != pivot_row && mat.row_has_bit(row, col) { mat.xor_row(row, pivot_row); } } @@ -169,7 +284,7 @@ impl F2Matrix { pub fn identity(n: usize) -> Self { let mut mat = Self::zeros(n, n); for i in 0..n { - mat.rows[i][i] = 1; + mat.set(i, i, 1); } mat } @@ -188,9 +303,9 @@ impl F2Matrix { let mut aug = Self::zeros(n, 2 * n); for i in 0..n { for j in 0..n { - aug.rows[i][j] = self.rows[i][j]; + aug.set(i, j, self.get(i, j)); } - aug.rows[i][n + i] = 1; + aug.set(i, n + i, 1); } // Row-reduce the augmented matrix. @@ -199,7 +314,7 @@ impl F2Matrix { // Find pivot in this column at or below the diagonal let mut found = None; for row in col..n { - if aug.rows[row][col] == 1 { + if aug.row_has_bit(row, col) { found = Some(row); break; } @@ -212,7 +327,7 @@ impl F2Matrix { // Eliminate all other rows for row in 0..n { - if row != col && aug.rows[row][col] == 1 { + if row != col && aug.row_has_bit(row, col) { aug.xor_row(row, col); } } @@ -221,7 +336,9 @@ impl F2Matrix { // Extract the inverse from the right half let mut inv = Self::zeros(n, n); for i in 0..n { - inv.rows[i] = aug.rows[i][n..2 * n].to_vec(); + for j in 0..n { + inv.set(i, j, aug.get(i, n + j)); + } } Some(inv) } @@ -237,13 +354,16 @@ impl F2Matrix { let m = self.num_rows(); let p = other.num_cols; let mut result = Self::zeros(m, p); + let other_t = other.transpose(); for i in 0..m { for j in 0..p { - let mut sum = 0u8; - for k in 0..self.num_cols { - sum ^= self.rows[i][k] & other.rows[k][j]; + let mut parity = 0u32; + for (a, b) in self.rows[i].iter().zip(other_t.row_words(j)) { + parity ^= (a & b).count_ones() & 1; + } + if parity != 0 { + result.set(i, j, 1); } - result.rows[i][j] = sum; } } result @@ -257,7 +377,7 @@ impl F2Matrix { let mut result = Self::zeros(n, m); for i in 0..m { for j in 0..n { - result.rows[j][i] = self.rows[i][j]; + result.set(j, i, self.get(i, j)); } } result @@ -275,7 +395,7 @@ impl F2Matrix { let mut aug = Self::zeros(m, n + n); for i in 0..m { for j in 0..n { - aug.rows[i][j] = self.rows[i][j]; + aug.set(i, j, self.get(i, j)); } } // We actually need to work with the transpose to find the right kernel. @@ -287,12 +407,12 @@ impl F2Matrix { let mut at = Self::zeros(n, m + n); for i in 0..m { for j in 0..n { - at.rows[j][i] = self.rows[i][j]; + at.set(j, i, self.get(i, j)); } } // Augment with identity in the right block for j in 0..n { - at.rows[j][m + j] = 1; + at.set(j, m + j, 1); } // Row-reduce A^T @@ -300,11 +420,11 @@ impl F2Matrix { // Rows that are zero in the A^T part (columns 0..m) give kernel vectors // from the identity part (columns m..m+n). - let mut basis = Vec::new(); + let mut basis: Vec> = Vec::new(); for i in 0..n { - if reduced.rows[i][..m].iter().all(|&b| b == 0) { + if (0..m).all(|col| reduced.get(i, col) == 0) { // This row's right block is a kernel vector - basis.push(reduced.rows[i][m..m + n].to_vec()); + basis.push((m..m + n).map(|col| reduced.get(i, col)).collect()); } } @@ -316,20 +436,14 @@ impl F2Matrix { // Handle overcounting: only keep linearly independent vectors if basis.len() > 1 { - let check = Self { - rows: basis.clone(), - num_cols: n, - }; + let check = Self::from_rows(basis.clone()); let (_, _ind_pivots) = check.row_reduce(); // The first ind_pivots.len() rows of the reduced form are independent // but we want the original basis vectors. Since we sorted, just take // the independent count. Actually, let's re-reduce properly. let (reduced_basis, _) = check.row_reduce(); - basis = reduced_basis - .rows - .into_iter() - .filter(|r| r.iter().any(|&b| b != 0)) - .collect(); + basis = reduced_basis.rows(); + basis.retain(|r| r.iter().any(|&b| b != 0)); } basis @@ -338,17 +452,17 @@ impl F2Matrix { impl fmt::Display for F2Matrix { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { - for (i, row) in self.rows.iter().enumerate() { + for i in 0..self.num_rows() { if i > 0 { writeln!(f)?; } // Show the X block and Z block separated by | let n = self.num_cols / 2; - for (j, &bit) in row.iter().enumerate() { + for j in 0..self.num_cols { if j == n { write!(f, "|")?; } - write!(f, "{bit}")?; + write!(f, "{}", self.get(i, j))?; } } Ok(()) @@ -374,7 +488,7 @@ impl fmt::Display for F2Matrix { /// /// ``` /// use pecos_quantum::PauliSequence; -/// use pecos_core::pauli::constructors::*; +/// use pecos_core::pauli::*; /// use pecos_core::PauliOperator; /// /// let gens = PauliSequence::new(vec![ @@ -501,10 +615,10 @@ impl PauliSequence { for (row_idx, generator) in self.paulis.iter().enumerate() { for q in generator.x_positions() { - mat.rows[row_idx][q] = 1; + mat.set(row_idx, q, 1); } for q in generator.z_positions() { - mat.rows[row_idx][n + q] = 1; + mat.set(row_idx, n + q, 1); } } @@ -519,7 +633,7 @@ impl PauliSequence { /// /// ``` /// use pecos_quantum::PauliSequence; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// // Two independent generators /// let gens = PauliSequence::new(vec![Zs(&[0, 1]), Zs(&[1, 2])]); @@ -545,7 +659,7 @@ impl PauliSequence { /// /// ``` /// use pecos_quantum::PauliSequence; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// let gens = PauliSequence::new(vec![Zs(&[0, 1]), Zs(&[1, 2])]); /// @@ -583,9 +697,7 @@ impl PauliSequence { // Eliminate the target using the reduced generators' pivots for (row_idx, &pivot_col) in pivots.iter().enumerate() { if target[pivot_col] == 1 { - for (col, t) in target.iter_mut().enumerate() { - *t ^= reduced.rows[row_idx][col]; - } + reduced.xor_row_into_dense(row_idx, &mut target); } } @@ -614,12 +726,12 @@ impl PauliSequence { for (row_idx, generator) in self.paulis.iter().enumerate() { for q in generator.x_positions() { - mat.rows[row_idx][q] = 1; + mat.set(row_idx, q, 1); } for q in generator.z_positions() { - mat.rows[row_idx][n + q] = 1; + mat.set(row_idx, n + q, 1); } - mat.rows[row_idx][2 * n + row_idx] = 1; + mat.set(row_idx, 2 * n + row_idx, 1); } let (reduced, pivots) = mat.row_reduce(); @@ -636,9 +748,7 @@ impl PauliSequence { // Eliminate the target using the reduced rows for (row_idx, &pivot_col) in pivots.iter().enumerate() { if target[pivot_col] == 1 { - for (col, t) in target.iter_mut().enumerate() { - *t ^= reduced.rows[row_idx][col]; - } + reduced.xor_row_into_dense(row_idx, &mut target); } } @@ -663,7 +773,7 @@ impl PauliSequence { /// /// ``` /// use pecos_quantum::PauliSequence; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// // Commuting generators /// let gens = PauliSequence::new(vec![Zs(&[0, 1]), Zs(&[1, 2])]); @@ -685,25 +795,63 @@ impl PauliSequence { true } - /// Returns the commutation matrix. + /// Returns the pairwise anticommutation matrix. /// - /// `result[i][j]` is `true` if entries `i` and `j` commute, `false` if they anticommute. - /// The diagonal is always `true` (every operator commutes with itself). + /// Entry `(i, j)` is `1` if entries `i` and `j` anticommute, and `0` if + /// they commute. The diagonal is always zero. #[must_use] - #[allow(clippy::needless_range_loop)] // symmetric update requires indexing both [i][j] and [j][i] - pub fn commutation_matrix(&self) -> Vec> { + pub fn commutation_matrix(&self) -> F2Matrix { let k = self.paulis.len(); - let mut matrix = vec![vec![true; k]; k]; + let n = self.num_qubits(); + let (x_rows, z_rows) = self.to_packed_xz_rows(n); + let mut matrix = F2Matrix::zeros(k, k); for i in 0..k { for j in (i + 1)..k { - let commutes = self.paulis[i].commutes_with(&self.paulis[j]); - matrix[i][j] = commutes; - matrix[j][i] = commutes; + if symplectic_inner_product(&x_rows[i], &z_rows[i], &x_rows[j], &z_rows[j]) != 0 { + matrix.set(i, j, 1); + matrix.set(j, i, 1); + } } } matrix } + /// Greedily partitions the sequence into mutually commuting groups. + /// + /// The returned groups preserve the input order within each group. This is + /// a graph-coloring heuristic on the anticommutation graph, so it is not + /// guaranteed to produce the minimum possible number of groups. + #[must_use] + pub fn group_commuting(&self) -> Vec { + let anticommutation = self.commutation_matrix(); + let mut groups: Vec> = Vec::new(); + + 'next_pauli: for pauli_idx in 0..self.paulis.len() { + for group in &mut groups { + if group + .iter() + .all(|&other_idx| anticommutation.get(pauli_idx, other_idx) == 0) + { + group.push(pauli_idx); + continue 'next_pauli; + } + } + groups.push(vec![pauli_idx]); + } + + groups + .into_iter() + .map(|group| { + PauliSequence::new( + group + .into_iter() + .map(|idx| self.paulis[idx].clone()) + .collect(), + ) + }) + .collect() + } + /// Returns the sequence in row-reduced form. /// /// This returns a new `PauliSequence` where the Pauli strings are independent @@ -722,7 +870,7 @@ impl PauliSequence { for col in 0..mat.num_cols { let mut found = None; for row in pivot_row..k { - if mat.rows[row][col] == 1 { + if mat.get(row, col) == 1 { found = Some(row); break; } @@ -736,7 +884,7 @@ impl PauliSequence { paulis.swap(pivot_row, found_row); for row in 0..k { - if row != pivot_row && mat.rows[row][col] == 1 { + if row != pivot_row && mat.get(row, col) == 1 { mat.xor_row(row, pivot_row); let pivot_ps = paulis[pivot_row].clone(); paulis[row] = paulis[row].clone() * pivot_ps; @@ -760,7 +908,7 @@ impl PauliSequence { /// /// ``` /// use pecos_quantum::PauliSequence; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// // Repetition code: ZZI, IZZ on 3 qubits /// // Centralizer dimension = 2n - rank = 6 - 2 = 4 @@ -785,12 +933,12 @@ impl PauliSequence { for (row_idx, generator) in self.paulis.iter().enumerate() { for q in generator.x_positions() { if q < n { - mat.rows[row_idx][q] = 1; + mat.set(row_idx, q, 1); } } for q in generator.z_positions() { if q < n { - mat.rows[row_idx][n + q] = 1; + mat.set(row_idx, n + q, 1); } } } @@ -799,13 +947,42 @@ impl PauliSequence { let mut s_omega = F2Matrix::zeros(mat.num_rows(), 2 * n); for i in 0..mat.num_rows() { for j in 0..n { - s_omega.rows[i][j] = mat.rows[i][n + j]; // Z block -> first half - s_omega.rows[i][n + j] = mat.rows[i][j]; // X block -> second half + s_omega.set(i, j, mat.get(i, n + j)); // Z block -> first half + s_omega.set(i, n + j, mat.get(i, j)); // X block -> second half } } s_omega.kernel() } + + fn to_packed_xz_rows(&self, num_qubits: usize) -> (Vec>, Vec>) { + let num_words = num_qubits.div_ceil(F2Matrix::WORD_BITS); + let mut x_rows = vec![vec![0u64; num_words]; self.paulis.len()]; + let mut z_rows = vec![vec![0u64; num_words]; self.paulis.len()]; + for (row, pauli) in self.paulis.iter().enumerate() { + for q in pauli.x_positions() { + if q < num_qubits { + let (word, mask) = F2Matrix::word_mask(q); + x_rows[row][word] |= mask; + } + } + for q in pauli.z_positions() { + if q < num_qubits { + let (word, mask) = F2Matrix::word_mask(q); + z_rows[row][word] |= mask; + } + } + } + (x_rows, z_rows) + } +} + +fn symplectic_inner_product(x_a: &[u64], z_a: &[u64], x_b: &[u64], z_b: &[u64]) -> u8 { + let mut parity = 0u32; + for (((&xa, &za), &xb), &zb) in x_a.iter().zip(z_a).zip(x_b).zip(z_b) { + parity ^= ((xa & zb) ^ (za & xb)).count_ones() & 1; + } + u8::from(parity != 0) } impl PauliSequence { @@ -840,7 +1017,7 @@ impl PauliSequence { /// /// ``` /// use pecos_quantum::PauliSequence; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// let seq = PauliSequence::new(vec![X(0) & Z(2), Z(1)]); /// assert_eq!(seq.to_sparse_str(), "+X0 Z2\n+Z1"); @@ -911,7 +1088,7 @@ impl fmt::Display for PauliSequence { #[cfg(test)] mod tests { use super::*; - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; #[test] fn test_new() { @@ -1017,16 +1194,67 @@ mod tests { let gens = PauliSequence::new(vec![X(0), Z(0), Y(0)]); let cm = gens.commutation_matrix(); // X,Z anticommute - assert!(!cm[0][1]); - assert!(!cm[1][0]); + assert_eq!(cm.get(0, 1), 1); + assert_eq!(cm.get(1, 0), 1); // X,Y anticommute - assert!(!cm[0][2]); + assert_eq!(cm.get(0, 2), 1); // Z,Y anticommute - assert!(!cm[1][2]); + assert_eq!(cm.get(1, 2), 1); // Self-commutation - assert!(cm[0][0]); - assert!(cm[1][1]); - assert!(cm[2][2]); + assert_eq!(cm.get(0, 0), 0); + assert_eq!(cm.get(1, 1), 0); + assert_eq!(cm.get(2, 2), 0); + } + + #[test] + fn test_commutation_matrix_matches_pairwise_across_packed_words() { + let gens = PauliSequence::new(vec![ + X(0), + Z(0), + X(65) & Z(130), + Z(65), + Y(130), + Zs([0, 65, 130]), + ]); + let cm = gens.commutation_matrix(); + + assert_eq!(cm.num_rows(), gens.len()); + assert_eq!(cm.num_cols(), gens.len()); + for i in 0..gens.len() { + for j in 0..gens.len() { + let expected = u8::from(!gens.paulis()[i].commutes_with(&gens.paulis()[j])); + assert_eq!(cm.get(i, j), expected, "entry ({i}, {j})"); + } + } + } + + #[test] + fn group_commuting_partitions_into_abelian_sequences() { + let gens = PauliSequence::new(vec![X(0), Z(0), X(1), Z(1)]); + let groups = gens.group_commuting(); + + assert_eq!(groups.len(), 2); + assert_eq!(groups[0].paulis(), &[X(0), X(1)]); + assert_eq!(groups[1].paulis(), &[Z(0), Z(1)]); + assert!(groups.iter().all(PauliSequence::is_abelian)); + } + + #[test] + fn group_commuting_handles_empty_single_and_all_commuting_inputs() { + let empty = PauliSequence::new(Vec::new()); + assert!(empty.group_commuting().is_empty()); + + let single = PauliSequence::new(vec![X(3)]); + let single_groups = single.group_commuting(); + assert_eq!(single_groups.len(), 1); + assert_eq!(single_groups[0].paulis(), &[X(3)]); + assert!(single_groups[0].is_abelian()); + + let commuting = PauliSequence::new(vec![Z(0), Z(1), Zs([0, 1]), X(2)]); + let commuting_groups = commuting.group_commuting(); + assert_eq!(commuting_groups.len(), 1); + assert_eq!(commuting_groups[0].paulis(), commuting.paulis()); + assert!(commuting_groups[0].is_abelian()); } #[test] @@ -1200,9 +1428,7 @@ mod tests { #[test] fn test_f2_kernel() { // Identity matrix: kernel is empty - let mut mat = F2Matrix::zeros(2, 2); - mat.rows[0][0] = 1; - mat.rows[1][1] = 1; + let mat = F2Matrix::from_rows(vec![vec![1, 0], vec![0, 1]]); assert!(mat.kernel().is_empty()); // Zero matrix 2x3: kernel dimension = 3 @@ -1213,9 +1439,7 @@ mod tests { #[test] fn test_f2_kernel_rank_deficient() { // [[1,0,0],[1,0,0]]: rank 1, kernel dimension = 3 - 1 = 2 - let mut mat = F2Matrix::zeros(2, 3); - mat.rows[0][0] = 1; - mat.rows[1][0] = 1; + let mat = F2Matrix::from_rows(vec![vec![1, 0, 0], vec![1, 0, 0]]); let kern = mat.kernel(); assert_eq!(kern.len(), 2); // Each kernel vector should satisfy A * v = 0 @@ -1230,9 +1454,7 @@ mod tests { #[test] fn test_f2_kernel_rectangular() { // 1x4 matrix [1,1,0,0]: kernel dim = 3 - let mut mat = F2Matrix::zeros(1, 4); - mat.rows[0][0] = 1; - mat.rows[0][1] = 1; + let mat = F2Matrix::from_rows(vec![vec![1, 1, 0, 0]]); let kern = mat.kernel(); assert_eq!(kern.len(), 3); } @@ -1304,24 +1526,21 @@ mod tests { let seq = PauliSequence::new(vec![Y(0)]); let mat = seq.to_symplectic_matrix(); // For 1 qubit, symplectic vector is [x0, z0] - assert_eq!(mat.rows[0], vec![1, 1], "Y should set both x and z bits"); + assert_eq!(mat.row(0), vec![1, 1], "Y should set both x and z bits"); } #[test] fn test_f2_matrix_row_reduce_empty() { let mat = F2Matrix::zeros(0, 3); let (reduced, pivots) = mat.row_reduce(); - assert!(reduced.rows.is_empty()); + assert_eq!(reduced.num_rows(), 0); assert!(pivots.is_empty()); } #[test] fn test_f2_matrix_kernel_tall_matrix() { // More rows than columns: 3x2 matrix - let mut mat = F2Matrix::zeros(3, 2); - mat.rows[0] = vec![1, 0]; - mat.rows[1] = vec![0, 1]; - mat.rows[2] = vec![1, 1]; // row 2 = row 0 + row 1 (redundant) + let mat = F2Matrix::from_rows(vec![vec![1, 0], vec![0, 1], vec![1, 1]]); // Full column rank => kernel is empty let kern = mat.kernel(); assert!( @@ -1333,10 +1552,7 @@ mod tests { #[test] fn test_f2_matrix_kernel_identity() { // Identity matrix: full rank, trivial kernel - let mut mat = F2Matrix::zeros(3, 3); - mat.rows[0] = vec![1, 0, 0]; - mat.rows[1] = vec![0, 1, 0]; - mat.rows[2] = vec![0, 0, 1]; + let mat = F2Matrix::identity(3); let kern = mat.kernel(); assert!(kern.is_empty()); } @@ -1382,9 +1598,7 @@ mod tests { #[test] fn test_f2_invert_swap_matrix() { // Swap rows 0 and 1: [[0,1],[1,0]] - let mut m = F2Matrix::zeros(2, 2); - m.rows[0][1] = 1; - m.rows[1][0] = 1; + let m = F2Matrix::from_rows(vec![vec![0, 1], vec![1, 0]]); let inv = m.invert().unwrap(); // Swap is self-inverse assert_eq!(inv, m); @@ -1393,10 +1607,7 @@ mod tests { #[test] fn test_f2_invert_upper_triangular() { // [[1,1],[0,1]] over GF(2) is self-inverse - let mut m = F2Matrix::zeros(2, 2); - m.rows[0][0] = 1; - m.rows[0][1] = 1; - m.rows[1][1] = 1; + let m = F2Matrix::from_rows(vec![vec![1, 1], vec![0, 1]]); let inv = m.invert().unwrap(); assert_eq!(inv, m); } @@ -1404,9 +1615,7 @@ mod tests { #[test] fn test_f2_invert_singular() { // [[1,1],[1,1]] is singular - let mut m = F2Matrix::zeros(2, 2); - m.rows[0] = vec![1, 1]; - m.rows[1] = vec![1, 1]; + let m = F2Matrix::from_rows(vec![vec![1, 1], vec![1, 1]]); assert!(m.invert().is_none()); } @@ -1419,26 +1628,56 @@ mod tests { #[test] fn test_f2_mul() { // [[1,1],[0,1]] * [[1,0],[1,1]] = [[0,1],[1,1]] over GF(2) - let mut a = F2Matrix::zeros(2, 2); - a.rows[0] = vec![1, 1]; - a.rows[1] = vec![0, 1]; - - let mut b = F2Matrix::zeros(2, 2); - b.rows[0] = vec![1, 0]; - b.rows[1] = vec![1, 1]; + let a = F2Matrix::from_rows(vec![vec![1, 1], vec![0, 1]]); + let b = F2Matrix::from_rows(vec![vec![1, 0], vec![1, 1]]); let c = a.mul(&b); - assert_eq!(c.rows[0], vec![0, 1]); - assert_eq!(c.rows[1], vec![1, 1]); + assert_eq!(c.row(0), vec![0, 1]); + assert_eq!(c.row(1), vec![1, 1]); + } + + #[test] + fn test_f2_mul_matches_dense_reference_across_word_boundaries() { + fn dense_reference(a: &[Vec], b: &[Vec]) -> Vec> { + let rows = a.len(); + let inner = b.len(); + let cols = b.first().map_or(0, Vec::len); + let mut out = vec![vec![0; cols]; rows]; + for i in 0..rows { + for j in 0..cols { + let mut bit = 0; + for (k, b_row) in b.iter().enumerate().take(inner) { + bit ^= a[i][k] & b_row[j]; + } + out[i][j] = bit; + } + } + out + } + + let a_rows: Vec> = (0..5) + .map(|row| { + (0..130) + .map(|col| u8::from((row * 17 + col * 11 + row * col) % 7 < 3)) + .collect() + }) + .collect(); + let b_rows: Vec> = (0..130) + .map(|row| { + (0..7) + .map(|col| u8::from((row * 5 + col * 13 + row * col) % 11 < 5)) + .collect() + }) + .collect(); + + let packed = F2Matrix::from_rows(a_rows.clone()).mul(&F2Matrix::from_rows(b_rows.clone())); + assert_eq!(packed.rows(), dense_reference(&a_rows, &b_rows)); } #[test] fn test_f2_mul_inverse_gives_identity() { // Invertible 3x3 matrix over GF(2) - let mut m = F2Matrix::zeros(3, 3); - m.rows[0] = vec![1, 1, 0]; - m.rows[1] = vec![0, 1, 1]; - m.rows[2] = vec![1, 1, 1]; + let m = F2Matrix::from_rows(vec![vec![1, 1, 0], vec![0, 1, 1], vec![1, 1, 1]]); let inv = m.invert().unwrap(); let product = m.mul(&inv); @@ -1451,15 +1690,13 @@ mod tests { #[test] fn test_f2_transpose() { - let mut m = F2Matrix::zeros(2, 3); - m.rows[0] = vec![1, 0, 1]; - m.rows[1] = vec![0, 1, 0]; + let m = F2Matrix::from_rows(vec![vec![1, 0, 1], vec![0, 1, 0]]); let t = m.transpose(); assert_eq!(t.num_rows(), 3); assert_eq!(t.num_cols(), 2); - assert_eq!(t.rows[0], vec![1, 0]); - assert_eq!(t.rows[1], vec![0, 1]); - assert_eq!(t.rows[2], vec![1, 0]); + assert_eq!(t.row(0), vec![1, 0]); + assert_eq!(t.row(1), vec![0, 1]); + assert_eq!(t.row(2), vec![1, 0]); } // ======================================================================== @@ -1536,16 +1773,15 @@ mod tests { #[test] fn test_f2_identity_1x1() { let id = F2Matrix::identity(1); - assert_eq!(id.rows[0], vec![1]); + assert_eq!(id.row(0), vec![1]); } #[test] fn test_f2_invert_1x1() { // [[1]] is invertible - let mut m = F2Matrix::zeros(1, 1); - m.rows[0] = vec![1]; + let m = F2Matrix::identity(1); let inv = m.invert().unwrap(); - assert_eq!(inv.rows[0], vec![1]); + assert_eq!(inv.row(0), vec![1]); // [[0]] is not invertible let z = F2Matrix::zeros(1, 1); @@ -1555,10 +1791,7 @@ mod tests { #[test] fn test_f2_mul_identity() { let id = F2Matrix::identity(3); - let mut m = F2Matrix::zeros(3, 3); - m.rows[0] = vec![1, 1, 0]; - m.rows[1] = vec![0, 1, 1]; - m.rows[2] = vec![1, 1, 1]; + let m = F2Matrix::from_rows(vec![vec![1, 1, 0], vec![0, 1, 1], vec![1, 1, 1]]); // I * A = A assert_eq!(id.mul(&m), m); @@ -1566,21 +1799,34 @@ mod tests { assert_eq!(m.mul(&id), m); } + #[test] + fn test_f2_matrix_crosses_multiple_words() { + let mut m = F2Matrix::zeros(3, 130); + m.set(0, 0, 1); + m.set(0, 64, 1); + m.set(1, 65, 1); + m.set(2, 129, 1); + + assert_eq!(m.get(0, 0), 1); + assert_eq!(m.get(0, 64), 1); + assert_eq!(m.get(1, 65), 1); + assert_eq!(m.get(2, 129), 1); + let (_, pivots) = m.row_reduce(); + assert_eq!(pivots.len(), 3); + assert_eq!(m.transpose().transpose(), m); + } + #[test] fn test_f2_transpose_square() { - let mut m = F2Matrix::zeros(2, 2); - m.rows[0] = vec![1, 1]; - m.rows[1] = vec![0, 1]; + let m = F2Matrix::from_rows(vec![vec![1, 1], vec![0, 1]]); let t = m.transpose(); - assert_eq!(t.rows[0], vec![1, 0]); - assert_eq!(t.rows[1], vec![1, 1]); + assert_eq!(t.row(0), vec![1, 0]); + assert_eq!(t.row(1), vec![1, 1]); } #[test] fn test_f2_double_transpose() { - let mut m = F2Matrix::zeros(2, 3); - m.rows[0] = vec![1, 0, 1]; - m.rows[1] = vec![0, 1, 0]; + let m = F2Matrix::from_rows(vec![vec![1, 0, 1], vec![0, 1, 0]]); let tt = m.transpose().transpose(); assert_eq!(tt, m); } @@ -1588,11 +1834,12 @@ mod tests { #[test] fn test_f2_invert_4x4() { // A larger invertible matrix over GF(2) - let mut m = F2Matrix::zeros(4, 4); - m.rows[0] = vec![1, 0, 0, 1]; - m.rows[1] = vec![0, 1, 0, 1]; - m.rows[2] = vec![0, 0, 1, 1]; - m.rows[3] = vec![1, 1, 1, 0]; + let m = F2Matrix::from_rows(vec![ + vec![1, 0, 0, 1], + vec![0, 1, 0, 1], + vec![0, 0, 1, 1], + vec![1, 1, 1, 0], + ]); let inv = m.invert().unwrap(); assert_eq!(m.mul(&inv), F2Matrix::identity(4)); diff --git a/crates/pecos-quantum/src/pauli_set.rs b/crates/pecos-quantum/src/pauli_set.rs index 41cdf83e2..8e947b5d6 100644 --- a/crates/pecos-quantum/src/pauli_set.rs +++ b/crates/pecos-quantum/src/pauli_set.rs @@ -34,7 +34,7 @@ //! //! ``` //! use pecos_quantum::PauliSet; -//! use pecos_core::pauli::constructors::*; +//! use pecos_core::pauli::*; //! //! let mut set = PauliSet::new(); //! set.insert(&X(0)); @@ -112,7 +112,7 @@ impl PauliKey { /// /// ``` /// use pecos_quantum::PauliSet; -/// use pecos_core::pauli::constructors::*; +/// use pecos_core::pauli::*; /// /// let a = PauliSet::from_iter([X(0), Z(1), X(0) & Z(1)]); /// let b = PauliSet::from_iter([Z(1), Y(2)]); @@ -233,7 +233,7 @@ impl PauliSet { /// /// ``` /// use pecos_quantum::PauliSet; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// let commuting = PauliSet::from_iter([Zs(&[0, 1]), Zs(&[1, 2])]); /// assert!(commuting.is_abelian()); @@ -262,7 +262,7 @@ impl PauliSet { /// /// ``` /// use pecos_quantum::PauliSet; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// let set = PauliSet::from_iter([X(0), Z(1)]); /// let s = set.to_sparse_str(); @@ -283,7 +283,7 @@ impl PauliSet { /// /// ``` /// use pecos_quantum::PauliSet; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// let set = PauliSet::from_iter([X(0), Z(1)]); /// let s = set.to_dense_str(); @@ -308,7 +308,7 @@ impl FromStr for PauliSet { /// /// ``` /// use pecos_quantum::PauliSet; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// use std::str::FromStr; /// /// let set: PauliSet = "{X0, Z1}".parse().unwrap(); @@ -415,7 +415,7 @@ impl fmt::Display for PauliSet { #[cfg(test)] mod tests { use super::*; - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; #[test] fn test_new_empty() { diff --git a/crates/pecos-quantum/src/stabilizer_group.rs b/crates/pecos-quantum/src/stabilizer_group.rs index 856f52523..c62206153 100644 --- a/crates/pecos-quantum/src/stabilizer_group.rs +++ b/crates/pecos-quantum/src/stabilizer_group.rs @@ -31,7 +31,7 @@ //! //! ``` //! use pecos_quantum::PauliStabilizerGroup; -//! use pecos_core::pauli::constructors::*; +//! use pecos_core::pauli::*; //! //! // Repetition code stabilizers //! let stab = PauliStabilizerGroup::new(vec![ @@ -94,7 +94,7 @@ impl std::error::Error for PauliStabilizerGroupError {} /// /// ``` /// use pecos_quantum::PauliStabilizerGroup; -/// use pecos_core::pauli::constructors::*; +/// use pecos_core::pauli::*; /// /// // 5-qubit code stabilizers: XZZXI, IXZZX, XIXZZ, ZXIXZ /// let stab = PauliStabilizerGroup::new(vec![ @@ -245,7 +245,7 @@ impl PauliStabilizerGroup { /// /// ``` /// use pecos_quantum::PauliStabilizerGroup; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// use pecos_core::PauliOperator; /// /// let stab = PauliStabilizerGroup::new(vec![Zs(&[0, 1]), Zs(&[1, 2])]).unwrap(); @@ -284,7 +284,7 @@ impl PauliStabilizerGroup { /// /// ``` /// use pecos_quantum::PauliStabilizerGroup; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// use pecos_core::PauliOperator; /// /// let stab = PauliStabilizerGroup::new(vec![Zs(&[0, 1]), Zs(&[1, 2])]).unwrap(); @@ -315,7 +315,7 @@ impl PauliStabilizerGroup { /// /// ``` /// use pecos_quantum::PauliStabilizerGroup; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// /// let stab = PauliStabilizerGroup::new(vec![Zs(&[0, 1]), Zs(&[1, 2])]).unwrap(); /// let elements: Vec<_> = stab.elements().collect(); @@ -332,9 +332,11 @@ impl PauliStabilizerGroup { self.inner.to_symplectic_matrix() } - /// Returns the commutation matrix (always all-true for a valid stabilizer group). + /// Returns the pairwise anticommutation matrix. + /// + /// This is always all-zero for a valid stabilizer group. #[must_use] - pub fn commutation_matrix(&self) -> Vec> { + pub fn commutation_matrix(&self) -> F2Matrix { self.inner.commutation_matrix() } @@ -375,7 +377,7 @@ impl PauliStabilizerGroup { /// /// ``` /// use pecos_quantum::PauliStabilizerGroup; - /// use pecos_core::pauli::constructors::*; + /// use pecos_core::pauli::*; /// use pecos_core::clifford_rep::CliffordRep; /// /// // Repetition code stabilizers: ZZ_, _ZZ @@ -497,7 +499,7 @@ impl fmt::Display for PauliStabilizerGroup { #[cfg(test)] mod tests { use super::*; - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; use pecos_core::{Pauli, PauliOperator}; #[test] @@ -1042,6 +1044,19 @@ mod tests { } } + #[test] + fn commutation_matrix_delegates_as_all_zero_anticommutation_matrix() { + let stab = PauliStabilizerGroup::new(vec![Zs([0, 1]), Zs([1, 2])]).unwrap(); + let mat = stab.commutation_matrix(); + assert_eq!(mat.num_rows(), 2); + assert_eq!(mat.num_cols(), 2); + for row in 0..mat.num_rows() { + for col in 0..mat.num_cols() { + assert_eq!(mat.get(row, col), 0); + } + } + } + // ======================================================================== // as_group() accessor // ======================================================================== diff --git a/crates/pecos-quantum/src/tick_circuit.rs b/crates/pecos-quantum/src/tick_circuit.rs index 8b65edeba..dbfc3ed8b 100644 --- a/crates/pecos-quantum/src/tick_circuit.rs +++ b/crates/pecos-quantum/src/tick_circuit.rs @@ -40,9 +40,11 @@ //! //! assert_eq!(circuit.num_ticks(), 6); //! -//! // Preps and measurements break the chain but allow .meta(): +//! // Preps break the chain but allow .meta(): //! circuit.tick().pz(&[0]).meta("reason", pecos_quantum::Attribute::String("init".into())); -//! circuit.tick().mz(&[0]).meta("basis", pecos_quantum::Attribute::String("Z".into())); +//! // Measurements return refs for annotations: +//! let ms = circuit.tick().mz(&[0]); +//! circuit.detector(&ms); //! //! // Tick-level metadata: call meta() before adding gates //! use pecos_quantum::Attribute; @@ -58,15 +60,125 @@ //! circuit3.tick().h(&[0, 1, 2, 3]); // H on 4 qubits at once //! circuit3.tick().cx(&[(0, 1), (2, 3)]); // 2 CX gates in parallel //! circuit3.tick().mz(&[0, 1, 2, 3]); // Measure all 4 qubits +//! +//! assert_eq!(circuit3.gate_count(), 14); // individual gate applications +//! assert_eq!(circuit3.gate_batch_count(), 4); // stored same-type batches //! ``` use pecos_core::gate_type::GateType; -use pecos_core::{Angle64, Gate, GateQubits, GateSignature, QubitId, TimeUnits}; -use std::collections::{BTreeMap, BTreeSet, HashMap}; +use pecos_core::{ + Angle64, ChannelExpr, Gate, GateMeasIds, GateQubits, GateSignature, MeasId, QubitId, TimeUnits, +}; +use std::collections::{BTreeMap, BTreeSet}; use crate::Attribute; -use crate::dag_circuit::DagCircuit; +use crate::dag_circuit::{AnnotationKind, DagCircuit, PauliAnnotation}; use std::fmt; +use std::ops::{Deref, Index}; + +fn meta_json_array(circuit: &TickCircuit, key: &str) -> Result, String> { + let Some(attr) = circuit.get_meta(key) else { + return Ok(Vec::new()); + }; + match attr { + Attribute::String(s) => { + if s.trim().is_empty() { + return Ok(Vec::new()); + } + serde_json::from_str::>(s) + .map_err(|e| format!("metadata {key:?} must be a JSON array: {e}")) + } + Attribute::Json(serde_json::Value::Array(values)) => Ok(values.clone()), + _ => Err(format!( + "metadata {key:?} must be a JSON array string or JSON array" + )), + } +} + +fn set_meta_json_array( + circuit: &mut TickCircuit, + key: &str, + values: &[serde_json::Value], +) -> Result<(), String> { + let json = + serde_json::to_string(values).map_err(|e| format!("could not serialize {key:?}: {e}"))?; + circuit.set_meta(key, Attribute::String(json)); + Ok(()) +} + +fn json_metadata_id(key: &str, id: u64) -> Result { + usize::try_from(id).map_err(|_| format!("metadata {key:?} id {id} does not fit usize")) +} + +fn next_metadata_id(values: &[serde_json::Value], key: &str) -> Result { + let mut max_id = None; + for value in values { + if let Some(id) = value.get("id").and_then(serde_json::Value::as_u64) { + let id = json_metadata_id(key, id)?; + max_id = Some(max_id.map_or(id, |max_id: usize| max_id.max(id))); + } + } + match max_id { + Some(max_id) => max_id + .checked_add(1) + .ok_or_else(|| format!("metadata {key:?} id counter overflow")), + None => Ok(values.len()), + } +} + +fn metadata_count(values: &[serde_json::Value], key: &str) -> Result { + let mut count = values.len(); + for value in values { + if let Some(id) = value.get("id").and_then(serde_json::Value::as_u64) { + let next = json_metadata_id(key, id)? + .checked_add(1) + .ok_or_else(|| format!("metadata {key:?} count overflow"))?; + count = count.max(next); + } + } + Ok(count) +} + +fn metadata_count_attr(values: &[serde_json::Value], key: &str) -> Result { + let count = metadata_count(values, key)?; + let count = i64::try_from(count) + .map_err(|_| format!("metadata {key:?} count {count} does not fit i64"))?; + Ok(Attribute::Int(count)) +} + +fn ensure_unique_metadata_id( + values: &[serde_json::Value], + key: &str, + id_name: &str, + id: usize, +) -> Result<(), String> { + for value in values { + if let Some(existing) = value.get("id").and_then(serde_json::Value::as_u64) { + let existing = json_metadata_id(key, existing)?; + if existing == id { + return Err(format!("{key} metadata already contains {id_name} {id}")); + } + } + } + Ok(()) +} + +fn observable_id_from_label(label: Option<&str>) -> Result, String> { + let Some(label) = label else { + return Ok(None); + }; + let Some(rest) = label.strip_prefix('L') else { + return Ok(None); + }; + if rest.is_empty() { + return Ok(None); + } + rest.parse::().map(Some).map_err(|_| { + format!( + "observable label {label:?} starts with 'L' but does not contain a valid integer id" + ) + }) +} /// Error when trying to add a gate that uses a qubit already in use in this tick. #[derive(Debug, Clone, PartialEq, Eq)] @@ -103,6 +215,53 @@ impl fmt::Display for QubitConflictError { impl std::error::Error for QubitConflictError {} +/// Error when trying to add a gate to a tick. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum TickGateError { + /// The gate payload itself is invalid. + InvalidGate { + /// Validation error from [`Gate::validate`]. + message: String, + /// The tick index where the invalid gate was being inserted. + tick_idx: Option, + }, + /// The gate is valid, but overlaps a qubit already used in this tick. + QubitConflict(QubitConflictError), +} + +impl TickGateError { + fn set_tick_idx(&mut self, tick_idx: usize) { + match self { + Self::InvalidGate { tick_idx: idx, .. } => *idx = Some(tick_idx), + Self::QubitConflict(err) => err.tick_idx = Some(tick_idx), + } + } +} + +impl fmt::Display for TickGateError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::InvalidGate { + message, + tick_idx: Some(tick_idx), + } => write!(f, "Invalid gate in tick {tick_idx}: {message}"), + Self::InvalidGate { + message, + tick_idx: None, + } => write!(f, "Invalid gate: {message}"), + Self::QubitConflict(err) => write!(f, "{err}"), + } + } +} + +impl std::error::Error for TickGateError {} + +impl From for TickGateError { + fn from(e: QubitConflictError) -> Self { + Self::QubitConflict(e) + } +} + /// Error when a custom gate is used with a different signature than previously established. #[derive(Debug, Clone, PartialEq, Eq)] pub struct GateSignatureMismatchError { @@ -133,6 +292,7 @@ impl std::error::Error for GateSignatureMismatchError {} #[derive(Debug, Clone)] pub enum CustomGateError { SignatureMismatch(GateSignatureMismatchError), + InvalidGate(String), QubitConflict(QubitConflictError), } @@ -140,6 +300,7 @@ impl fmt::Display for CustomGateError { fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { match self { Self::SignatureMismatch(e) => write!(f, "{e}"), + Self::InvalidGate(e) => write!(f, "{e}"), Self::QubitConflict(e) => write!(f, "{e}"), } } @@ -159,17 +320,301 @@ impl From for CustomGateError { } } +impl From for CustomGateError { + fn from(e: TickGateError) -> Self { + match e { + TickGateError::InvalidGate { message, .. } => Self::InvalidGate(message), + TickGateError::QubitConflict(err) => Self::QubitConflict(err), + } + } +} + +#[derive(Debug, Clone, Default)] +struct TickGateStorage { + commands: Vec, +} + +impl TickGateStorage { + fn len(&self) -> usize { + self.commands.len() + } + + fn is_empty(&self) -> bool { + self.commands.is_empty() + } + + fn as_slice(&self) -> &[Gate] { + &self.commands + } + + fn iter(&self) -> std::slice::Iter<'_, Gate> { + self.commands.iter() + } + + fn get(&self, idx: usize) -> Option<&Gate> { + self.commands.get(idx) + } + + fn push(&mut self, gate: Gate) { + self.commands.push(gate); + } + + fn set(&mut self, idx: usize, gate: Gate) { + self.commands[idx] = gate; + } + + fn remove(&mut self, idx: usize) -> Gate { + self.commands.remove(idx) + } + + fn append_batch(&mut self, idx: usize, gate: Gate) { + assert!( + self.commands[idx].can_batch_with(&gate), + "cannot merge incompatible gate batches" + ); + self.commands[idx].append_batch(gate); + } + + fn truncate_payload(&mut self, idx: usize, qubit_len: usize, meas_id_len: usize) { + self.commands[idx].qubits.truncate(qubit_len); + self.commands[idx].meas_ids.truncate(meas_id_len); + } +} + +impl Deref for TickGateStorage { + type Target = [Gate]; + + fn deref(&self) -> &Self::Target { + self.as_slice() + } +} + +impl Index for TickGateStorage { + type Output = Gate; + + fn index(&self, index: usize) -> &Self::Output { + &self.commands[index] + } +} + /// A single time slice containing gates that execute in parallel. #[derive(Debug, Clone, Default)] pub struct Tick { - /// Gates in this tick (all act on disjoint qubits). - gates: Vec, - /// Metadata for each gate, indexed by position in `gates`. - gate_attrs: BTreeMap>, + /// Gate batches in this tick (all act on disjoint qubits). + gate_batches: TickGateStorage, + /// Metadata for each gate batch, indexed by position in `gate_batches`. + batch_attrs: Vec>>, /// Tick-level metadata. attrs: BTreeMap, } +#[derive(Debug, Clone, Copy)] +struct GateBatchPiece { + gate_idx: usize, + qubit_start: usize, + qubit_len: usize, + meas_id_start: usize, + meas_id_len: usize, +} + +/// Borrowed view of one stored same-type gate batch in a [`Tick`]. +#[derive(Debug, Clone, Copy)] +pub struct GateBatchRef<'a> { + batch_index: usize, + gate: &'a Gate, + attrs: Option<&'a BTreeMap>, +} + +impl<'a> GateBatchRef<'a> { + fn new( + batch_index: usize, + gate: &'a Gate, + attrs: Option<&'a BTreeMap>, + ) -> Self { + Self { + batch_index, + gate, + attrs, + } + } + + /// Return this batch's index within its tick. + #[must_use] + pub fn batch_index(self) -> usize { + self.batch_index + } + + /// Return the underlying stored [`Gate`] batch. + #[must_use] + pub fn as_gate(self) -> &'a Gate { + self.gate + } + + /// Return the number of individual gates represented by this batch. + #[must_use] + pub fn gate_count(self) -> usize { + self.gate.num_gates() + } + + /// Return the metadata attribute with the given key, if present. + #[must_use] + pub fn get_attr(self, key: &str) -> Option<&'a Attribute> { + self.attrs.and_then(|attrs| attrs.get(key)) + } + + /// Iterate over metadata attributes attached to this batch. + pub fn attrs(self) -> impl Iterator { + self.attrs.into_iter().flat_map(|attrs| attrs.iter()) + } + + /// Return one individual gate from this batch. + #[must_use] + pub fn instance(self, instance_index: usize) -> Option> { + let gate_count = self.gate_count(); + if instance_index >= gate_count { + return None; + } + + let qubits = if gate_count == 1 { + self.gate.qubits.as_slice() + } else { + let arity = self.gate.quantum_arity(); + let start = instance_index * arity; + let end = start + arity; + self.gate.qubits.get(start..end)? + }; + + let meas_ids = if self.gate.meas_ids.is_empty() { + &self.gate.meas_ids[0..0] + } else if gate_count == 1 { + self.gate.meas_ids.as_slice() + } else { + if !self.gate.meas_ids.len().is_multiple_of(gate_count) { + return None; + } + let arity = self.gate.meas_ids.len() / gate_count; + let start = instance_index * arity; + let end = start + arity; + self.gate.meas_ids.get(start..end)? + }; + + Some(GateInstanceRef { + batch: self, + instance_index, + qubits, + meas_ids, + }) + } + + /// Iterate over individual gates represented by this batch. + pub fn iter_gate_instances(self) -> impl Iterator> { + (0..self.gate_count()).filter_map(move |idx| self.instance(idx)) + } +} + +impl Deref for GateBatchRef<'_> { + type Target = Gate; + + fn deref(&self) -> &Self::Target { + self.gate + } +} + +/// Borrowed view of one individual gate inside a [`GateBatchRef`]. +#[derive(Debug, Clone, Copy)] +pub struct GateInstanceRef<'a> { + batch: GateBatchRef<'a>, + instance_index: usize, + qubits: &'a [QubitId], + meas_ids: &'a [MeasId], +} + +impl<'a> GateInstanceRef<'a> { + /// Return the stored batch this individual gate came from. + #[must_use] + pub fn batch(self) -> GateBatchRef<'a> { + self.batch + } + + /// Return the parent batch's index within its tick. + #[must_use] + pub fn batch_index(self) -> usize { + self.batch.batch_index() + } + + /// Return this gate's position within its stored batch. + #[must_use] + pub fn instance_index(self) -> usize { + self.instance_index + } + + /// Return this individual gate's type. + #[must_use] + pub fn gate_type(self) -> GateType { + self.batch.gate_type + } + + /// Return this individual gate's qubit support. + #[must_use] + pub fn qubits(self) -> &'a [QubitId] { + self.qubits + } + + /// Return this individual gate's measurement ids, if any. + #[must_use] + pub fn meas_ids(self) -> &'a [MeasId] { + self.meas_ids + } + + /// Return this individual gate's rotation angles. + #[must_use] + pub fn angles(self) -> &'a [Angle64] { + self.batch.gate.angles.as_slice() + } + + /// Return this individual gate's non-angle parameters. + #[must_use] + pub fn params(self) -> &'a [f64] { + self.batch.gate.params.as_slice() + } + + /// Return this individual gate's channel payload, if this is a channel. + #[must_use] + pub fn channel(self) -> Option<&'a ChannelExpr> { + self.batch.gate.channel.as_ref() + } + + /// Return the metadata attribute with the given key, if present. + #[must_use] + pub fn get_attr(self, key: &str) -> Option<&'a Attribute> { + self.batch.get_attr(key) + } + + /// Iterate over metadata attributes attached to the parent batch. + pub fn attrs(self) -> impl Iterator { + self.batch.attrs() + } + + /// Materialize this individual gate as an owned [`Gate`]. + /// + /// The returned gate carries this instance's sliced qubits and measurement + /// ids, plus the parent batch's gate type, angles, parameters, and channel + /// payload. Batch metadata is intentionally not copied into the `Gate`; + /// use [`attrs`](Self::attrs) when metadata needs to travel alongside the + /// materialized operation. + #[must_use] + pub fn to_gate(self) -> Gate { + Gate { + gate_type: self.gate_type(), + qubits: self.qubits.iter().copied().collect::(), + angles: self.batch.gate.angles.clone(), + params: self.batch.gate.params.clone(), + meas_ids: self.meas_ids.iter().copied().collect::(), + channel: self.batch.gate.channel.clone(), + } + } +} + impl Tick { /// Create a new empty tick. #[must_use] @@ -177,53 +622,291 @@ impl Tick { Self::default() } - /// Get the number of gates in this tick. + /// Get the number of stored gate batches in this tick. + /// + /// This is the number of stored gate batches. Batched commands such as + /// `cx(&[(0, 1), (2, 3)])` count as one stored batch. #[must_use] pub fn len(&self) -> usize { - self.gates.len() + self.gate_batches.len() + } + + /// Get the number of individual gate applications in this tick. + /// + /// Batched commands count by the number of gates they represent: + /// `h(&[0, 1, 2])` counts as three H gates, and + /// `cx(&[(0, 1), (2, 3)])` counts as two CX gates. + #[must_use] + pub fn gate_count(&self) -> usize { + self.gate_batches.iter().map(Gate::num_gates).sum() + } + + /// Get the number of compatible gate batches in this tick. + /// + /// Gates with the same type, parameters, payload shape, and metadata can + /// execute as one batch when they differ only by disjoint qubits. + #[must_use] + pub fn gate_batch_count(&self) -> usize { + let mut representative_indices: Vec = Vec::new(); + 'gate: for (idx, gate) in self.gate_batches.iter().enumerate() { + for &rep_idx in &representative_indices { + if self.gate_attrs_equivalent(rep_idx, idx) + && self.gate_batches[rep_idx].can_batch_with(gate) + { + continue 'gate; + } + } + representative_indices.push(idx); + } + representative_indices.len() } /// Check if the tick is empty. #[must_use] pub fn is_empty(&self) -> bool { - self.gates.is_empty() + self.gate_batches.is_empty() } - /// Get the gates in this tick. + /// Get the raw stored gate batches in this tick. + /// + /// In `TickCircuit`, a stored [`Gate`] command may represent one or more + /// individual gates on disjoint qubits. For example, + /// `cx(&[(0, 1), (2, 3)])` is one batch containing two gates. + /// + /// The batches preserve the complete [`Gate`] payload, including + /// measurement IDs and typed channel payloads. + /// + /// Prefer [`iter_gate_batches`](Self::iter_gate_batches) for new read-only + /// consumers; it returns [`GateBatchRef`] values that carry the batch index + /// and metadata alongside the underlying `Gate`. This raw slice accessor is + /// kept for compatibility, storage inspection, and code that intentionally + /// needs stable batch indices. #[must_use] - pub fn gates(&self) -> &[Gate] { - &self.gates + pub fn gate_batches(&self) -> &[Gate] { + self.gate_batches.as_slice() + } + + /// Iterate over full-fidelity borrowed gate-batch views in this tick. + pub fn iter_gate_batches(&self) -> impl Iterator> { + self.gate_batches + .iter() + .enumerate() + .map(|(idx, gate)| GateBatchRef::new(idx, gate, self.normalized_gate_attrs(idx))) } - /// Get mutable access to the gates in this tick. - pub fn gates_mut(&mut self) -> &mut [Gate] { - &mut self.gates + /// Iterate over individual gates expanded from this tick's stored batches. + pub fn iter_gate_instances(&self) -> impl Iterator> { + self.iter_gate_batches() + .flat_map(GateBatchRef::iter_gate_instances) } /// Add a gate to this tick. + /// + /// # Panics + /// + /// Panics if [`Gate::validate`] rejects the gate payload or if the gate + /// conflicts with an existing gate in this tick. Use + /// [`try_add_gate`](Self::try_add_gate) for fallible insertion. pub fn add_gate(&mut self, gate: Gate) -> usize { - let idx = self.gates.len(); - self.gates.push(gate); + self.try_add_gate(gate) + .unwrap_or_else(|err| panic!("{err}")) + } + + fn push_gate_unchecked(&mut self, gate: Gate) -> usize { + let idx = self.gate_batches.len(); + self.gate_batches.push(gate); + self.batch_attrs.push(None); idx } + fn push_gate_unchecked_piece(&mut self, gate: Gate) -> GateBatchPiece { + let qubit_len = gate.qubits.len(); + let meas_id_len = gate.meas_ids.len(); + let gate_idx = self.push_gate_unchecked(gate); + GateBatchPiece { + gate_idx, + qubit_start: 0, + qubit_len, + meas_id_start: 0, + meas_id_len, + } + } + + fn normalized_gate_attrs(&self, gate_idx: usize) -> Option<&BTreeMap> { + self.batch_attrs + .get(gate_idx) + .and_then(Option::as_ref) + .filter(|attrs| !attrs.is_empty()) + } + + fn gate_attrs_equivalent(&self, a: usize, b: usize) -> bool { + self.normalized_gate_attrs(a) == self.normalized_gate_attrs(b) + } + + fn gate_has_no_attrs(&self, gate_idx: usize) -> bool { + self.normalized_gate_attrs(gate_idx).is_none() + } + + fn compatible_empty_attr_batch(&self, gate: &Gate) -> Option { + self.gate_batches + .iter() + .enumerate() + .find(|(idx, existing)| self.gate_has_no_attrs(*idx) && existing.can_batch_with(gate)) + .map(|(idx, _)| idx) + } + + fn whole_gate_piece(&self, gate_idx: usize) -> GateBatchPiece { + let gate = &self.gate_batches[gate_idx]; + GateBatchPiece { + gate_idx, + qubit_start: 0, + qubit_len: gate.qubits.len(), + meas_id_start: 0, + meas_id_len: gate.meas_ids.len(), + } + } + + fn merge_compatible_piece_at(&mut self, piece: GateBatchPiece) -> GateBatchPiece { + if piece.gate_idx >= self.gate_batches.len() { + return piece; + } + + let Some(target_idx) = (0..piece.gate_idx).find(|&idx| { + self.gate_attrs_equivalent(idx, piece.gate_idx) + && self.gate_batches[idx].can_batch_with(&self.gate_batches[piece.gate_idx]) + }) else { + return piece; + }; + + let qubit_start = self.gate_batches[target_idx].qubits.len(); + let meas_id_start = self.gate_batches[target_idx].meas_ids.len(); + let gate = self.gate_batches[piece.gate_idx].clone(); + self.gate_batches.append_batch(target_idx, gate); + self.remove_gate(piece.gate_idx); + GateBatchPiece { + gate_idx: target_idx, + qubit_start, + qubit_len: piece.qubit_len, + meas_id_start, + meas_id_len: piece.meas_id_len, + } + } + + fn merge_compatible_gate_at(&mut self, gate_idx: usize) -> usize { + if gate_idx >= self.gate_batches.len() { + return gate_idx; + } + self.merge_compatible_piece_at(self.whole_gate_piece(gate_idx)) + .gate_idx + } + + fn isolate_batch_piece(&mut self, piece: GateBatchPiece) -> usize { + if piece.gate_idx >= self.gate_batches.len() { + return piece.gate_idx; + } + + let gate_qubit_len = self.gate_batches[piece.gate_idx].qubits.len(); + let gate_meas_id_len = self.gate_batches[piece.gate_idx].meas_ids.len(); + if piece.qubit_start == 0 + && piece.qubit_len == gate_qubit_len + && piece.meas_id_start == 0 + && piece.meas_id_len == gate_meas_id_len + { + return piece.gate_idx; + } + + assert_eq!( + piece.qubit_start + piece.qubit_len, + gate_qubit_len, + "batched gate metadata can only split the appended suffix" + ); + assert_eq!( + piece.meas_id_start + piece.meas_id_len, + gate_meas_id_len, + "batched gate metadata can only split the appended measurement-id suffix" + ); + + let mut split_gate = self.gate_batches[piece.gate_idx].clone(); + split_gate.qubits = self.gate_batches[piece.gate_idx].qubits[piece.qubit_start..].into(); + split_gate.meas_ids = + self.gate_batches[piece.gate_idx].meas_ids[piece.meas_id_start..].into(); + + self.gate_batches + .truncate_payload(piece.gate_idx, piece.qubit_start, piece.meas_id_start); + + let split_idx = self.push_gate_unchecked(split_gate); + if let Some(attrs) = self.normalized_gate_attrs(piece.gate_idx).cloned() { + self.batch_attrs[split_idx] = Some(attrs); + } + split_idx + } + + fn set_gate_attr_for_piece( + &mut self, + piece: GateBatchPiece, + key: &str, + value: Attribute, + ) -> GateBatchPiece { + let gate_idx = self.isolate_batch_piece(piece); + self.set_gate_attr(gate_idx, key, value); + self.merge_compatible_piece_at(self.whole_gate_piece(gate_idx)) + } + + fn set_gate_attrs_for_piece( + &mut self, + piece: GateBatchPiece, + attrs: BTreeMap, + ) -> GateBatchPiece { + let gate_idx = self.isolate_batch_piece(piece); + self.set_gate_attrs(gate_idx, attrs); + self.merge_compatible_piece_at(self.whole_gate_piece(gate_idx)) + } + /// Set metadata on a gate. - pub fn set_gate_attr(&mut self, gate_idx: usize, key: &str, value: Attribute) { - self.gate_attrs - .entry(gate_idx) - .or_default() + /// + /// Returns the gate index. + /// + /// # Panics + /// + /// Panics if `gate_idx` is not a valid stored batch index in this tick. + pub fn set_gate_attr(&mut self, gate_idx: usize, key: &str, value: Attribute) -> usize { + assert!( + gate_idx < self.gate_batches.len(), + "gate index {gate_idx} out of bounds" + ); + self.batch_attrs[gate_idx] + .get_or_insert_with(BTreeMap::new) .insert(key.to_string(), value); + gate_idx } /// Set multiple metadata attributes on a gate at once. - pub fn set_gate_attrs(&mut self, gate_idx: usize, attrs: BTreeMap) { - self.gate_attrs.entry(gate_idx).or_default().extend(attrs); + /// + /// Returns the gate index. + /// + /// # Panics + /// + /// Panics if `gate_idx` is not a valid stored batch index in this tick. + pub fn set_gate_attrs(&mut self, gate_idx: usize, attrs: BTreeMap) -> usize { + assert!( + gate_idx < self.gate_batches.len(), + "gate index {gate_idx} out of bounds" + ); + if !attrs.is_empty() { + self.batch_attrs[gate_idx] + .get_or_insert_with(BTreeMap::new) + .extend(attrs); + } + gate_idx } /// Get metadata from a gate. #[must_use] pub fn get_gate_attr(&self, gate_idx: usize, key: &str) -> Option<&Attribute> { - self.gate_attrs.get(&gate_idx).and_then(|m| m.get(key)) + self.batch_attrs + .get(gate_idx) + .and_then(Option::as_ref) + .and_then(|m| m.get(key)) } /// Set tick-level metadata. @@ -244,8 +927,9 @@ impl Tick { /// Get all attributes for a gate. pub fn gate_attrs(&self, gate_idx: usize) -> impl Iterator { - self.gate_attrs - .get(&gate_idx) + self.batch_attrs + .get(gate_idx) + .and_then(Option::as_ref) .into_iter() .flat_map(|m| m.iter()) } @@ -260,7 +944,7 @@ impl Tick { /// This is computed lazily by iterating through all gates. #[must_use] pub fn active_qubits(&self) -> BTreeSet { - self.gates + self.gate_batches .iter() .flat_map(|gate| gate.qubits.iter().copied()) .collect() @@ -269,7 +953,9 @@ impl Tick { /// Check if a specific qubit is already in use in this tick. #[must_use] pub fn uses_qubit(&self, qubit: QubitId) -> bool { - self.gates.iter().any(|gate| gate.qubits.contains(&qubit)) + self.gate_batches + .iter() + .any(|gate| gate.qubits.contains(&qubit)) } /// Check if any of the given qubits are already in use in this tick. @@ -285,20 +971,137 @@ impl Tick { .collect() } - /// Try to add a gate to this tick, returning an error if any qubit is already in use. + /// Try to add a gate to this tick. /// /// # Errors /// - /// Returns `QubitConflictError` if any qubit in the gate is already used by another gate in this tick. - pub fn try_add_gate(&mut self, gate: Gate) -> Result { - let conflicts = self.find_conflicts(&gate.qubits); + /// Returns [`TickGateError::InvalidGate`] if the gate payload is invalid, or + /// [`TickGateError::QubitConflict`] if any qubit in the gate is already used + /// by another gate in this tick. + pub(crate) fn try_add_gate_preserving_command( + &mut self, + gate: Gate, + ) -> Result { + gate.validate() + .map_err(|message| TickGateError::InvalidGate { + message, + tick_idx: None, + })?; + let conflicts = self.find_conflicts(&gate.qubits); + if !conflicts.is_empty() { + return Err(TickGateError::QubitConflict(QubitConflictError { + conflicting_qubits: conflicts, + tick_idx: None, + })); + } + Ok(self.push_gate_unchecked(gate)) + } + + /// Try to add a gate to this tick. + /// + /// # Errors + /// + /// Returns [`TickGateError::InvalidGate`] if the gate payload is invalid, or + /// [`TickGateError::QubitConflict`] if any qubit in the gate is already used + /// by another gate in this tick. + pub fn try_add_gate(&mut self, gate: Gate) -> Result { + self.try_add_gate_piece(gate).map(|piece| piece.gate_idx) + } + + fn try_add_gate_piece(&mut self, gate: Gate) -> Result { + gate.validate() + .map_err(|message| TickGateError::InvalidGate { + message, + tick_idx: None, + })?; + let conflicts = self.find_conflicts(&gate.qubits); if !conflicts.is_empty() { - return Err(QubitConflictError { + return Err(TickGateError::QubitConflict(QubitConflictError { conflicting_qubits: conflicts, tick_idx: None, + })); + } + if let Some(gate_idx) = self.compatible_empty_attr_batch(&gate) { + let piece = GateBatchPiece { + gate_idx, + qubit_start: self.gate_batches[gate_idx].qubits.len(), + qubit_len: gate.qubits.len(), + meas_id_start: self.gate_batches[gate_idx].meas_ids.len(), + meas_id_len: gate.meas_ids.len(), + }; + self.gate_batches.append_batch(gate_idx, gate); + return Ok(piece); + } + Ok(self.push_gate_unchecked_piece(gate)) + } + + /// Replace a stored gate batch while preserving storage invariants. + /// + /// # Errors + /// + /// Returns [`TickGateError::InvalidGate`] if `gate_idx` is out of bounds or + /// if the replacement gate payload is invalid. Returns + /// [`TickGateError::QubitConflict`] if the replacement overlaps another + /// command in this tick. + pub fn replace_gate_batch(&mut self, gate_idx: usize, gate: Gate) -> Result<(), TickGateError> { + if gate_idx >= self.gate_batches.len() { + return Err(TickGateError::InvalidGate { + message: format!("gate index {gate_idx} out of bounds"), + tick_idx: None, }); } - Ok(self.add_gate(gate)) + gate.validate() + .map_err(|message| TickGateError::InvalidGate { + message, + tick_idx: None, + })?; + + let mut active = BTreeSet::new(); + for (idx, existing) in self.gate_batches.iter().enumerate() { + if idx == gate_idx { + continue; + } + active.extend(existing.qubits.iter().copied()); + } + let conflicts: Vec = gate + .qubits + .iter() + .filter(|q| active.contains(q)) + .copied() + .collect(); + if !conflicts.is_empty() { + return Err(TickGateError::QubitConflict(QubitConflictError { + conflicting_qubits: conflicts, + tick_idx: None, + })); + } + + self.gate_batches.set(gate_idx, gate); + Ok(()) + } + + /// Mutate a stored gate batch through a temporary [`Gate`] value. + /// + /// The updated gate batch is validated with the same conflict checks as a + /// full replacement before it is written back. + /// + /// # Errors + /// + /// Propagates the same errors as [`replace_gate_batch`](Self::replace_gate_batch). + pub fn update_gate_batch( + &mut self, + gate_idx: usize, + update: impl FnOnce(&mut Gate), + ) -> Result<(), TickGateError> { + let Some(existing) = self.gate_batches.get(gate_idx) else { + return Err(TickGateError::InvalidGate { + message: format!("gate index {gate_idx} out of bounds"), + tick_idx: None, + }); + }; + let mut gate = existing.clone(); + update(&mut gate); + self.replace_gate_batch(gate_idx, gate) } /// Remove all gates that use any of the specified qubits. @@ -324,7 +1127,7 @@ impl Tick { // Find indices of gates to remove (those using any of the specified qubits) let indices_to_remove: Vec = self - .gates + .gate_batches .iter() .enumerate() .filter(|(_, gate)| gate.qubits.iter().any(|q| qubits_set.contains(q))) @@ -339,17 +1142,7 @@ impl Tick { // Remove gates in reverse order to preserve indices for &idx in indices_to_remove.iter().rev() { - self.gates.remove(idx); - } - - // Rebuild gate_attrs with updated indices - let old_attrs = std::mem::take(&mut self.gate_attrs); - for (old_idx, attrs) in old_attrs { - // Count how many removed indices are before this one - let shift = indices_to_remove.iter().filter(|&&i| i < old_idx).count(); - if !indices_to_remove.contains(&old_idx) { - self.gate_attrs.insert(old_idx - shift, attrs); - } + let _ = self.remove_gate(idx); } removed_count @@ -374,22 +1167,12 @@ impl Tick { /// assert_eq!(tick.len(), 2); // H and Z remain /// ``` pub fn remove_gate(&mut self, idx: usize) -> Option { - if idx >= self.gates.len() { + if idx >= self.gate_batches.len() { return None; } - let gate = self.gates.remove(idx); - - // Rebuild gate_attrs with updated indices - let old_attrs = std::mem::take(&mut self.gate_attrs); - for (old_idx, attrs) in old_attrs { - if old_idx < idx { - self.gate_attrs.insert(old_idx, attrs); - } else if old_idx > idx { - self.gate_attrs.insert(old_idx - 1, attrs); - } - // Skip old_idx == idx (removed gate's attrs) - } + let gate = self.gate_batches.remove(idx); + self.batch_attrs.remove(idx); Some(gate) } @@ -425,6 +1208,23 @@ impl Tick { /// circuit.tick().cx(&[(0, 1), (2, 3)]); // Multiple CX gates /// circuit.tick().mz(&[0, 1, 2, 3]); // Measure multiple qubits /// ``` +/// Measurement reference in a tick circuit: (`tick_index`, `gate_index`, qubit). +/// +/// Returned by `mz()` for use in `detector()` and `observable()` annotations. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub struct TickMeasRef { + /// Tick index. + pub tick: usize, + /// Gate index within the tick. + pub gate_idx: usize, + /// Qubit that was measured. + pub qubit: QubitId, + /// Measurement record index (cumulative count of MZ qubits in circuit order). + pub record_idx: usize, + /// Stable measurement result identity (SSA value). + pub meas_id: MeasId, +} + #[derive(Debug, Clone, Default)] pub struct TickCircuit { /// The sequence of ticks. @@ -434,7 +1234,45 @@ pub struct TickCircuit { /// Circuit-level metadata. circuit_attrs: BTreeMap, /// Gate signatures for custom gate validation (JIT + AOT). - gate_signatures: HashMap, + gate_signatures: BTreeMap, + /// Unified Pauli annotations (detectors, observables, operators). + annotations: Vec, + /// Running count of measurement records (incremented by each MZ qubit). + next_meas_record: usize, +} + +/// Maps an ideal circuit gate to zero or more channel annotations. +/// +/// [`TickCircuit::with_noise`] uses this trait to compile an ideal circuit into +/// an annotated circuit with explicit channel operations interleaved after the +/// ideal gates that triggered them. +pub trait GateNoiseModel { + /// Returns channel operations that should be placed after `gate`. + fn channels_after(&self, gate: &Gate) -> Vec; +} + +impl GateNoiseModel for F +where + F: Fn(&Gate) -> Vec, +{ + fn channels_after(&self, gate: &Gate) -> Vec { + self(gate) + } +} + +fn schedule_channel_gate(noise_ticks: &mut Vec, gate: Gate) { + let mut pending = Some(gate); + for tick in noise_ticks.iter_mut() { + let gate_ref = pending.as_ref().expect("pending gate is present"); + if tick.find_conflicts(&gate_ref.qubits).is_empty() { + tick.add_gate(pending.take().expect("pending gate is present")); + return; + } + } + + let mut tick = Tick::new(); + tick.add_gate(pending.expect("pending gate is present")); + noise_ticks.push(tick); } /// Handle to a specific tick for adding gates. @@ -445,6 +1283,7 @@ pub struct TickHandle<'a> { circuit: &'a mut TickCircuit, tick_idx: usize, last_gate_idx: Option, + last_gate_piece: Option, } /// Handle returned by preparation operations on a tick. @@ -454,7 +1293,7 @@ pub struct TickHandle<'a> { pub struct TickPrepHandle<'a> { circuit: &'a mut TickCircuit, tick_idx: usize, - gate_idx: usize, + gate_piece: GateBatchPiece, } impl TickPrepHandle<'_> { @@ -463,7 +1302,7 @@ impl TickPrepHandle<'_> { /// Returns `()` to break the chain. pub fn meta(self, key: &str, value: impl Into) { if let Some(tick) = self.circuit.get_tick_mut(self.tick_idx) { - tick.set_gate_attr(self.gate_idx, key, value.into()); + tick.set_gate_attr_for_piece(self.gate_piece, key, value.into()); } } @@ -472,7 +1311,7 @@ impl TickPrepHandle<'_> { /// Returns `()` to break the chain. pub fn metas(self, attrs: BTreeMap) { if let Some(tick) = self.circuit.get_tick_mut(self.tick_idx) { - tick.set_gate_attrs(self.gate_idx, attrs); + tick.set_gate_attrs_for_piece(self.gate_piece, attrs); } } } @@ -484,7 +1323,7 @@ impl TickPrepHandle<'_> { pub struct TickMeasureHandle<'a> { circuit: &'a mut TickCircuit, tick_idx: usize, - gate_idx: usize, + gate_piece: GateBatchPiece, } impl TickMeasureHandle<'_> { @@ -493,7 +1332,7 @@ impl TickMeasureHandle<'_> { /// Returns `()` to break the chain. pub fn meta(self, key: &str, value: impl Into) { if let Some(tick) = self.circuit.get_tick_mut(self.tick_idx) { - tick.set_gate_attr(self.gate_idx, key, value.into()); + tick.set_gate_attr_for_piece(self.gate_piece, key, value.into()); } } @@ -502,7 +1341,7 @@ impl TickMeasureHandle<'_> { /// Returns `()` to break the chain. pub fn metas(self, attrs: BTreeMap) { if let Some(tick) = self.circuit.get_tick_mut(self.tick_idx) { - tick.set_gate_attrs(self.gate_idx, attrs); + tick.set_gate_attrs_for_piece(self.gate_piece, attrs); } } } @@ -515,7 +1354,9 @@ impl TickCircuit { ticks: Vec::new(), next_tick: 0, circuit_attrs: BTreeMap::new(), - gate_signatures: HashMap::new(), + gate_signatures: BTreeMap::new(), + annotations: Vec::new(), + next_meas_record: 0, } } @@ -525,10 +1366,68 @@ impl TickCircuit { self.ticks.len() } - /// Get the total number of gates across all ticks. + /// Total number of measurement results produced so far. + #[must_use] + pub fn num_measurements(&self) -> usize { + self.next_meas_record + } + + /// Advance the measurement counter by `n` (for external MZ gate construction). + pub fn advance_meas_counter(&mut self, n: usize) { + self.next_meas_record += n; + } + + /// Get the total number of individual gate applications across all ticks. + /// + /// Batched commands count by individual gate. For example, + /// `cx(&[(0, 1), (2, 3)])` contributes two gates. + /// + /// # Examples + /// + /// ``` + /// use pecos_quantum::TickCircuit; + /// + /// let mut circuit = TickCircuit::new(); + /// circuit.tick().h(&[0, 1, 2]); + /// circuit.tick().cx(&[(0, 1), (2, 3)]); + /// + /// assert_eq!(circuit.gate_count(), 5); + /// assert_eq!(circuit.gate_batch_count(), 2); + /// ``` #[must_use] pub fn gate_count(&self) -> usize { - self.ticks.iter().map(Tick::len).sum() + self.ticks.iter().map(Tick::gate_count).sum() + } + + /// Get the total number of compatible gate batches across all ticks. + /// + /// A batch is a stored command group that can execute together because the + /// gates are identical except for disjoint qubit support and compatible + /// metadata. + /// + /// # Examples + /// + /// ``` + /// use pecos_quantum::TickCircuit; + /// + /// let mut circuit = TickCircuit::new(); + /// circuit.tick().h(&[0]).h(&[1]).cx(&[(2, 3), (4, 5)]); + /// + /// assert_eq!(circuit.gate_count(), 4); + /// assert_eq!(circuit.gate_batch_count(), 2); // one H batch, one CX batch + /// ``` + #[must_use] + pub fn gate_batch_count(&self) -> usize { + self.ticks.iter().map(Tick::gate_batch_count).sum() + } + + /// Convert a per-tick gate index to a global gate index. + /// + /// Global index = sum of stored gate batches for all ticks before + /// `tick_idx` + `gate_idx`. + #[must_use] + pub fn global_gate_index(&self, tick_idx: usize, gate_idx: usize) -> usize { + self.ticks[..tick_idx].iter().map(Tick::len).sum::() + gate_idx } /// Get a tick by index. @@ -538,6 +1437,13 @@ impl TickCircuit { } /// Get a mutable tick by index. + /// + /// Mutating a tick through this handle must preserve the usual + /// `TickCircuit` invariants: each stored [`Gate`] must validate, qubits may + /// not overlap within a tick, and gate metadata must stay aligned with the + /// stored batches. Prefer `Tick` methods such as + /// [`Tick::add_gate`], [`Tick::remove_gate`], and + /// [`Tick::replace_gate_batch`] over direct structural rewrites. pub fn get_tick_mut(&mut self, idx: usize) -> Option<&mut Tick> { self.ticks.get_mut(idx) } @@ -548,11 +1454,37 @@ impl TickCircuit { &self.ticks } - /// Get mutable access to all ticks. + /// Get mutable access to all ticks as a slice. + /// + /// This is an escape hatch for passes that need to mutate existing ticks in + /// place. Do not reorder, insert, or remove ticks through this slice. Keep + /// each tick's gate/metadata invariants intact by using `Tick` mutation + /// methods rather than editing stored batches directly. pub fn ticks_mut(&mut self) -> &mut [Tick] { &mut self.ticks } + /// Remove all ticks from this circuit for an internal structural rewrite. + /// + /// This is crate-private because a partially drained circuit has + /// temporarily invalid tick structure. Pair it with + /// [`replace_ticks`](Self::replace_ticks) in the same transformation. + pub(crate) fn take_ticks(&mut self) -> Vec { + let ticks = std::mem::take(&mut self.ticks); + self.next_tick = 0; + ticks + } + + /// Replace all ticks after an internal structural rewrite. + /// + /// Updates `next_tick` to match the replacement length. The caller remains + /// responsible for preserving measurement record numbering, annotation + /// references, tick ordering, and each tick's gate/metadata alignment. + pub(crate) fn replace_ticks(&mut self, ticks: Vec) { + self.ticks = ticks; + self.next_tick = self.ticks.len(); + } + /// Export as a plain ASCII circuit diagram. /// /// Produces horizontal qubit-wire lines with gate symbols placed at each @@ -638,7 +1570,7 @@ impl TickCircuit { let layers: Vec> = self .ticks .iter() - .map(|t| t.gates().iter().collect()) + .map(|t| t.gate_batches().iter().collect()) .collect(); let num_qubits = self.all_qubits().len(); let header = format!( @@ -673,6 +1605,7 @@ impl TickCircuit { circuit: self, tick_idx, last_gate_idx: None, + last_gate_piece: None, } } @@ -692,6 +1625,96 @@ impl TickCircuit { self.circuit_attrs.get(key) } + /// Add detector metadata defined by measurement-record offsets. + /// + /// This appends one entry to the circuit-level `"detectors"` JSON metadata + /// list and updates `"num_detectors"`. It is intended for circuits whose + /// detector definitions are stored in metadata rather than as direct + /// [`TickMeasRef`] annotations. + /// + /// # Errors + /// + /// Returns an error if existing detector metadata is not a JSON array, if + /// JSON serialization fails, or if `detector_id` duplicates an existing + /// explicit detector id. + pub fn add_detector_metadata( + &mut self, + records: &[i64], + coords: Option<&[f64]>, + label: Option<&str>, + detector_id: Option, + ) -> Result { + let mut detectors = meta_json_array(self, "detectors")?; + let id = match detector_id { + Some(id) => id, + None => next_metadata_id(&detectors, "detectors")?, + }; + ensure_unique_metadata_id(&detectors, "detectors", "detector_id", id)?; + + let mut detector = serde_json::Map::new(); + detector.insert("id".to_string(), serde_json::json!(id)); + detector.insert("records".to_string(), serde_json::json!(records)); + if let Some(coords) = coords { + detector.insert("coords".to_string(), serde_json::json!(coords)); + } + if let Some(label) = label { + detector.insert("label".to_string(), serde_json::json!(label)); + } + detectors.push(serde_json::Value::Object(detector)); + set_meta_json_array(self, "detectors", &detectors)?; + self.set_meta( + "num_detectors", + metadata_count_attr(&detectors, "detectors")?, + ); + Ok(id) + } + + /// Add observable metadata defined by measurement-record offsets. + /// + /// Standard observables live in the decoder `L` id space. A label of + /// `"L3"` therefore selects observable id 3 unless `observable_id` is + /// provided, in which case the two must agree. + /// + /// # Errors + /// + /// Returns an error if existing observable metadata is not a JSON array, if + /// JSON serialization fails, if the label/id conflict, or if the selected + /// id duplicates an existing explicit observable id. + pub fn add_observable_metadata( + &mut self, + records: &[i64], + observable_id: Option, + label: Option<&str>, + ) -> Result { + let mut observables = meta_json_array(self, "observables")?; + let label_id = observable_id_from_label(label)?; + if let (Some(observable_id), Some(label_id)) = (observable_id, label_id) + && observable_id != label_id + { + return Err(format!( + "observable_id={observable_id} conflicts with label id L{label_id}" + )); + } + let id = observable_id + .or(label_id) + .map_or_else(|| next_metadata_id(&observables, "observables"), Ok)?; + ensure_unique_metadata_id(&observables, "observables", "observable_id", id)?; + + let mut observable = serde_json::Map::new(); + observable.insert("id".to_string(), serde_json::json!(id)); + observable.insert("records".to_string(), serde_json::json!(records)); + if let Some(label) = label { + observable.insert("label".to_string(), serde_json::json!(label)); + } + observables.push(serde_json::Value::Object(observable)); + set_meta_json_array(self, "observables", &observables)?; + self.set_meta( + "num_observables", + metadata_count_attr(&observables, "observables")?, + ); + Ok(id) + } + /// Get all circuit-level attributes. pub fn circuit_attrs(&self) -> impl Iterator { self.circuit_attrs.iter() @@ -755,6 +1778,75 @@ impl TickCircuit { self.next_tick = 0; self.circuit_attrs.clear(); self.gate_signatures.clear(); + self.annotations.clear(); + self.next_meas_record = 0; + } + + /// Try to compile an ideal circuit plus a gate-triggered noise model into + /// an annotated circuit containing explicit channel operations. + /// + /// The original gates are preserved. For each source tick, channel + /// operations returned by `noise.channels_after(gate)` are scheduled into + /// one or more immediately following ticks while respecting qubit + /// conflicts. This produces a concrete inline representation useful for + /// inspection, visualization, and simulators that consume interleaved + /// channel operations directly. + /// + /// For measurements, `channels_after` is literal: returned channels are + /// placed after the measurement operation. Physical pre-measurement noise + /// and classical readout flips should use explicit APIs for those concepts + /// instead of being hidden in this post-gate hook. + /// + /// # Errors + /// + /// Returns an error if the source circuit already contains channel + /// operations. Apply either inline channels or a noise model, not both. + pub fn try_with_noise(&self, noise: &N) -> Result { + for (tick_idx, tick) in self.iter_ticks() { + for gate in tick.iter_gate_batches() { + if gate.is_channel() { + let gate_idx = gate.batch_index(); + return Err(format!( + "with_noise cannot apply a noise model to a circuit that already contains channel operations (first channel at tick {tick_idx} gate {gate_idx})" + )); + } + } + } + + let mut out = Self::new(); + out.circuit_attrs.clone_from(&self.circuit_attrs); + out.gate_signatures.clone_from(&self.gate_signatures); + out.annotations.clone_from(&self.annotations); + out.next_meas_record = self.next_meas_record; + + for tick in &self.ticks { + out.ticks.push(tick.clone()); + + let mut noise_ticks = Vec::new(); + for gate in tick.iter_gate_batches() { + for channel in noise.channels_after(gate.as_gate()) { + schedule_channel_gate(&mut noise_ticks, Gate::channel(channel)); + } + } + out.ticks.extend(noise_ticks); + } + + out.next_tick = out.ticks.len(); + Ok(out) + } + + /// Compile an ideal circuit plus a gate-triggered noise model into an + /// annotated circuit containing explicit channel operations. + /// + /// This is the convenience form of [`try_with_noise`](Self::try_with_noise). + /// + /// # Panics + /// + /// Panics if the source circuit already contains channel operations. + #[must_use] + pub fn with_noise(&self, noise: &N) -> Self { + self.try_with_noise(noise) + .expect("with_noise requires an ideal circuit without existing channel operations") } /// Reserve empty ticks in advance. @@ -825,6 +1917,7 @@ impl TickCircuit { circuit: self, tick_idx: idx, last_gate_idx: None, + last_gate_piece: None, } } @@ -864,6 +1957,7 @@ impl TickCircuit { circuit: self, tick_idx: idx, last_gate_idx: None, + last_gate_piece: None, } } @@ -896,14 +1990,14 @@ impl TickCircuit { // --- Gate signature validation --- /// Import gate signatures in bulk (e.g., from a `GateRegistry`). - pub fn import_signatures(&mut self, sigs: &HashMap) { + pub fn import_signatures(&mut self, sigs: &BTreeMap) { self.gate_signatures .extend(sigs.iter().map(|(name, sig)| (name.clone(), sig.clone()))); } /// Get read access to the gate signatures. #[must_use] - pub fn gate_signatures(&self) -> &HashMap { + pub fn gate_signatures(&self) -> &BTreeMap { &self.gate_signatures } @@ -944,49 +2038,41 @@ impl TickCircuit { // --- Iteration helpers --- - /// Iterate over all gates in the circuit, across all ticks. - /// - /// Gates are yielded in tick order, then in order within each tick. - /// - /// # Examples - /// - /// ``` - /// use pecos_quantum::TickCircuit; + /// Iterate over full-fidelity gate batches in the circuit. /// - /// let mut circuit = TickCircuit::new(); - /// circuit.tick().h(&[0, 1]); - /// circuit.tick().cx(&[(0, 1)]); - /// - /// for gate in circuit.iter_gates() { - /// println!("{:?} on {:?}", gate.gate_type, gate.qubits); - /// } - /// ``` - pub fn iter_gates(&self) -> impl Iterator { - self.ticks.iter().flat_map(Tick::gates) + /// This is the preferred API for consumers that execute or analyze batched + /// commands. Each yielded [`Gate`] may represent multiple individual gates + /// on disjoint qubits, and carries the full gate payload. + pub fn iter_gate_batches(&self) -> impl Iterator> { + self.ticks.iter().flat_map(Tick::iter_gate_batches) } - /// Iterate over all gates with their tick index. - /// - /// Yields `(tick_index, gate)` pairs. - /// - /// # Examples - /// - /// ``` - /// use pecos_quantum::TickCircuit; - /// - /// let mut circuit = TickCircuit::new(); - /// circuit.tick().h(&[0]); - /// circuit.tick().x(&[0]); - /// - /// for (tick_idx, gate) in circuit.iter_gates_with_tick() { - /// println!("Tick {}: {:?}", tick_idx, gate.gate_type); - /// } - /// ``` - pub fn iter_gates_with_tick(&self) -> impl Iterator { + /// Returns true if any tick contains an explicit channel operation. + #[must_use] + pub fn has_channel_operations(&self) -> bool { + self.iter_gate_batches().any(|gate| gate.is_channel()) + } + + /// Iterate over full-fidelity gate batches with their tick index. + pub fn iter_gate_batches_with_tick(&self) -> impl Iterator)> { self.ticks .iter() .enumerate() - .flat_map(|(tick_idx, tick)| tick.gates().iter().map(move |gate| (tick_idx, gate))) + .flat_map(|(tick_idx, tick)| tick.iter_gate_batches().map(move |gate| (tick_idx, gate))) + } + + /// Iterate over individual gates expanded from stored batches. + pub fn iter_gate_instances(&self) -> impl Iterator> { + self.ticks.iter().flat_map(Tick::iter_gate_instances) + } + + /// Iterate over individual gates with their tick index. + pub fn iter_gate_instances_with_tick( + &self, + ) -> impl Iterator)> { + self.ticks.iter().enumerate().flat_map(|(tick_idx, tick)| { + tick.iter_gate_instances().map(move |gate| (tick_idx, gate)) + }) } /// Iterate over ticks with their index. @@ -998,7 +2084,8 @@ impl TickCircuit { /// /// let mut circuit = TickCircuit::new(); /// circuit.tick().h(&[0, 1, 2]); - /// circuit.tick().cx(&[(0, 1), (1, 2)]); + /// circuit.tick().cx(&[(0, 1)]); + /// circuit.tick().cx(&[(1, 2)]); /// /// for (tick_idx, tick) in circuit.iter_ticks() { /// println!("Tick {} has {} gates", tick_idx, tick.len()); @@ -1022,10 +2109,14 @@ impl TickCircuit { /// /// // Get all H gates /// let h_gates: Vec<_> = circuit.iter_gates_by_type(GateType::H).collect(); - /// assert_eq!(h_gates.len(), 1); // One Gate object with 3 qubits + /// assert_eq!(h_gates.len(), 1); // One H batch with 3 qubits /// ``` - pub fn iter_gates_by_type(&self, gate_type: GateType) -> impl Iterator { - self.iter_gates().filter(move |g| g.gate_type == gate_type) + pub fn iter_gates_by_type( + &self, + gate_type: GateType, + ) -> impl Iterator> { + self.iter_gate_batches() + .filter(move |g| g.gate_type == gate_type) } /// Get all qubits used in the circuit. @@ -1044,14 +2135,16 @@ impl TickCircuit { /// ``` #[must_use] pub fn all_qubits(&self) -> BTreeSet { - self.iter_gates() - .flat_map(|gate| gate.qubits.iter().copied()) + self.iter_gate_batches() + .flat_map(|gate| gate.as_gate().qubits.iter().copied()) .collect() } /// Count gates by type across the entire circuit. /// - /// Returns a map from `GateType` to count. + /// Returns a map from `GateType` to gate count. Batched commands + /// such as `cx(&[(0, 1), (2, 3)])` count as two CX gates even though they + /// are stored as one [`Gate`] carrying two disjoint pairs. /// /// # Examples /// @@ -1060,93 +2153,331 @@ impl TickCircuit { /// use pecos_core::gate_type::GateType; /// /// let mut circuit = TickCircuit::new(); - /// circuit.tick().h(&[0, 1, 2]); - /// circuit.tick().cx(&[(0, 1), (1, 2)]); + /// circuit.tick().h(&[0, 1, 2, 3]); + /// circuit.tick().cx(&[(0, 1), (2, 3)]); /// /// let counts = circuit.gate_counts_by_type(); - /// assert_eq!(counts.get(&GateType::H), Some(&1)); // 1 H gate object (with 3 qubits) - /// assert_eq!(counts.get(&GateType::CX), Some(&1)); // 1 CX gate object (with 2 pairs) + /// assert_eq!(counts.get(&GateType::H), Some(&4)); // 4 H gates + /// assert_eq!(counts.get(&GateType::CX), Some(&2)); // 2 CX gates /// ``` #[must_use] pub fn gate_counts_by_type(&self) -> BTreeMap { let mut counts = BTreeMap::new(); - for gate in self.iter_gates() { - *counts.entry(gate.gate_type).or_insert(0) += 1; + for gate in self.iter_gate_batches() { + *counts.entry(gate.gate_type).or_insert(0) += gate.num_gates(); } counts } -} + // ==================== Annotations ==================== -// --- TickHandle - handle for adding gates to a specific tick --- + /// Annotate a detector: measurements whose XOR should be deterministic. + pub fn detector(&mut self, measurements: &[TickMeasRef]) -> usize { + let meas_nodes: Vec = measurements.iter().map(|m| m.record_idx).collect(); + let pauli = pecos_core::PauliString::zs( + &measurements + .iter() + .map(|m| m.qubit.index()) + .collect::>(), + ); + let idx = self.annotations.len(); + self.annotations.push(PauliAnnotation { + pauli, + kind: AnnotationKind::Detector { + measurement_nodes: meas_nodes, + coords: Vec::new(), + }, + label: None, + }); + idx + } -impl<'a> TickHandle<'a> { - /// Get the tick index this handle refers to. + /// Annotate a labeled detector. + pub fn detector_labeled(&mut self, label: &str, measurements: &[TickMeasRef]) -> usize { + let idx = self.detector(measurements); + self.annotations[idx].label = Some(label.to_string()); + idx + } + + /// Annotate a logical observable. + pub fn observable(&mut self, measurements: &[TickMeasRef]) -> usize { + let meas_nodes: Vec = measurements.iter().map(|m| m.record_idx).collect(); + let pauli = pecos_core::PauliString::zs( + &measurements + .iter() + .map(|m| m.qubit.index()) + .collect::>(), + ); + let idx = self.annotations.len(); + self.annotations.push(PauliAnnotation { + pauli, + kind: AnnotationKind::Observable { + measurement_nodes: meas_nodes, + }, + label: None, + }); + idx + } + + /// Annotate a labeled observable. + pub fn observable_labeled(&mut self, label: &str, measurements: &[TickMeasRef]) -> usize { + let idx = self.observable(measurements); + self.annotations[idx].label = Some(label.to_string()); + idx + } + + /// Place a tracked-Pauli annotation. + 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.annotations.push(PauliAnnotation { + pauli, + kind: AnnotationKind::TrackedPauli, + label: None, + }); + idx + } + + /// Place a labeled tracked-Pauli annotation. + pub fn tracked_pauli_labeled(&mut self, label: &str, pauli: pecos_core::PauliString) -> usize { + let idx = self.tracked_pauli(pauli); + self.annotations[idx].label = Some(label.to_string()); + idx + } + + /// Get all annotations. #[must_use] - pub fn index(&self) -> usize { - self.tick_idx + pub fn annotations(&self) -> &[PauliAnnotation] { + &self.annotations } - /// Add a gate to this tick. + // ==================== Idle ==================== + + /// Insert identity gates for qubits not operated on during each tick. /// - /// # Panics + /// For each tick, finds qubits that are in the circuit's qubit set but + /// not actively operated on, and inserts an identity (I) gate. These + /// gates receive `p1` noise from the noise model, matching Stim's + /// convention of `DEPOLARIZE1` on idle qubits between ticks. /// - /// Panics if any qubit in the gate is already used by another gate in this tick. - /// Use `try_add_gate` for fallible gate addition. - fn add_gate(&mut self, gate: Gate) -> &mut Self { - match self.circuit.ticks[self.tick_idx].try_add_gate(gate) { - Ok(idx) => { - self.last_gate_idx = Some(idx); - self - } - Err(mut err) => { - err.tick_idx = Some(self.tick_idx); - panic!("{}", err); - } - } - } - - /// Try to add a gate to this tick, returning an error if any qubit is already in use. + /// This is separate from `GateType::Idle` which represents explicit + /// wait operations with duration-dependent `p_idle` noise. /// - /// # Errors + /// # Example /// - /// Returns `QubitConflictError` if any qubit in the gate is already used by another gate in this tick. - pub fn try_add_gate(&mut self, gate: Gate) -> Result<&mut Self, QubitConflictError> { - match self.circuit.ticks[self.tick_idx].try_add_gate(gate) { - Ok(idx) => { - self.last_gate_idx = Some(idx); - Ok(self) - } - Err(mut err) => { - err.tick_idx = Some(self.tick_idx); - Err(err) + /// ``` + /// use pecos_quantum::TickCircuit; + /// + /// let mut circuit = TickCircuit::new(); + /// circuit.tick().h(&[0]); + /// circuit.tick().cx(&[(0, 1)]); + /// circuit.tick().mz(&[0, 1]); + /// + /// 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() { + return; + } + + for tick in &mut self.ticks { + let active = tick.active_qubits(); + for &q in &all_qubits { + if !active.contains(&q) { + // Duration 1 = one tick of idling + let _ = tick.try_add_gate(Gate::idle(1.0, vec![q])); + } } } } - /// Add a gate and return the gate index. + /// Compact ticks by merging gates into earlier ticks when possible. /// - /// # Panics + /// ASAP scheduling: walk ticks in order, try to merge each tick's gates + /// into the latest tick where all qubits are free. Produces the minimum + /// number of ticks for the same gate dependency structure. /// - /// Panics if any qubit in the gate is already used by another gate in this tick. - fn add_gate_get_idx(&mut self, gate: Gate) -> usize { - match self.circuit.ticks[self.tick_idx].try_add_gate(gate) { - Ok(idx) => { - self.last_gate_idx = Some(idx); - idx + /// This is useful after replaying a serialized trace (e.g., from QIR) + /// where each gate gets its own tick even if they could run in parallel. + /// + /// Gate metadata and tick-level metadata are preserved. Tick-level + /// metadata from merged ticks is dropped (the target tick's metadata + /// wins). + /// + /// # Panics + /// + /// Panics if an existing gate in the circuit fails validation while being + /// moved into its compacted tick. Circuits built through `TickCircuit` + /// constructors already validate gates at insertion time. + /// + /// # Example + /// + /// ``` + /// use pecos_quantum::TickCircuit; + /// + /// let mut circuit = TickCircuit::new(); + /// // Serialized: each gate in its own tick + /// circuit.tick().h(&[0]); + /// circuit.tick().h(&[1]); + /// circuit.tick().cx(&[(0, 1)]); + /// assert_eq!(circuit.num_ticks(), 3); + /// + /// circuit.compact_ticks(); + /// // H(0) and H(1) merged into one tick; CX(0,1) stays separate + /// assert_eq!(circuit.num_ticks(), 2); + /// ``` + pub fn compact_ticks(&mut self) { + if self.ticks.len() <= 1 { + return; + } + + let old_ticks: Vec = self.ticks.drain(..).collect(); + let mut compacted: Vec = Vec::new(); + + for tick in old_ticks { + let mut placed = false; + + // Try to merge into the latest existing tick where all qubits are free. + // Walk backwards to find the latest valid target (ASAP scheduling). + for target_idx in (0..compacted.len()).rev() { + let can_merge = tick.gate_batches.iter().all(|gate| { + gate.qubits + .iter() + .all(|q| !compacted[target_idx].uses_qubit(*q)) + }); + + if can_merge { + // Check that no tick between target+1..end uses any of these qubits + // (would violate ordering). + let all_clear = (target_idx + 1..compacted.len()).all(|between| { + tick.gate_batches.iter().all(|gate| { + gate.qubits + .iter() + .all(|q| !compacted[between].uses_qubit(*q)) + }) + }); + + if all_clear { + // Move gates and their per-gate metadata into the target tick. + for (gi, gate) in tick.gate_batches.iter().enumerate() { + if let Some(attrs) = tick.normalized_gate_attrs(gi) { + let new_idx = compacted[target_idx] + .try_add_gate_preserving_command(gate.clone()) + .unwrap_or_else(|err| panic!("{err}")); + compacted[target_idx].set_gate_attrs(new_idx, attrs.clone()); + compacted[target_idx].merge_compatible_gate_at(new_idx); + } else { + compacted[target_idx].add_gate(gate.clone()); + } + } + placed = true; + break; + } + } + } + + if !placed { + compacted.push(tick); + } + } + + self.ticks = compacted; + self.next_tick = self.ticks.len(); + } +} + +// --- TickHandle - handle for adding gates to a specific tick --- + +impl<'a> TickHandle<'a> { + /// Get the tick index this handle refers to. + #[must_use] + pub fn index(&self) -> usize { + self.tick_idx + } + + /// Add a gate to this tick. + /// + /// # Panics + /// + /// Panics if the gate payload is invalid or any qubit in the gate is + /// already used by another gate in this tick. + /// Use `try_add_gate` for fallible gate addition. + fn add_gate(&mut self, gate: Gate) -> &mut Self { + match self.circuit.ticks[self.tick_idx].try_add_gate_piece(gate) { + Ok(piece) => { + self.last_gate_idx = Some(piece.gate_idx); + self.last_gate_piece = Some(piece); + self + } + Err(mut err) => { + err.set_tick_idx(self.tick_idx); + panic!("{}", err); + } + } + } + + /// Try to add a gate to this tick. + /// + /// # Errors + /// + /// Returns [`TickGateError::InvalidGate`] if the gate payload is invalid, or + /// [`TickGateError::QubitConflict`] if any qubit in the gate is already used + /// by another gate in this tick. + pub fn try_add_gate(&mut self, gate: Gate) -> Result<&mut Self, TickGateError> { + match self.circuit.ticks[self.tick_idx].try_add_gate_piece(gate) { + Ok(piece) => { + self.last_gate_idx = Some(piece.gate_idx); + self.last_gate_piece = Some(piece); + Ok(self) + } + Err(mut err) => { + err.set_tick_idx(self.tick_idx); + Err(err) + } + } + } + + /// Add a gate and return the gate index. + /// + /// # Panics + /// + /// Panics if the gate payload is invalid or any qubit in the gate is + /// already used by another gate in this tick. + fn add_gate_get_piece(&mut self, gate: Gate) -> GateBatchPiece { + match self.circuit.ticks[self.tick_idx].try_add_gate_piece(gate) { + Ok(piece) => { + self.last_gate_idx = Some(piece.gate_idx); + self.last_gate_piece = Some(piece); + piece } Err(mut err) => { - err.tick_idx = Some(self.tick_idx); + err.set_tick_idx(self.tick_idx); panic!("{}", err); } } } + fn add_gate_get_idx(&mut self, gate: Gate) -> usize { + self.add_gate_get_piece(gate).gate_idx + } + /// Set metadata on the last added gate. /// /// If no gate has been added yet, sets tick-level metadata instead. pub fn meta(&mut self, key: &str, value: impl Into) -> &mut Self { - if let Some(gate_idx) = self.last_gate_idx { - self.circuit.ticks[self.tick_idx].set_gate_attr(gate_idx, key, value.into()); + if let Some(piece) = self.last_gate_piece { + let piece = + self.circuit.ticks[self.tick_idx].set_gate_attr_for_piece(piece, key, value.into()); + self.last_gate_idx = Some(piece.gate_idx); + self.last_gate_piece = Some(piece); } else { // No gate yet - set tick-level metadata self.circuit.ticks[self.tick_idx].set_attr(key, value.into()); @@ -1158,8 +2489,10 @@ impl<'a> TickHandle<'a> { /// /// If no gate has been added yet, sets tick-level metadata instead. pub fn metas(&mut self, attrs: BTreeMap) -> &mut Self { - if let Some(gate_idx) = self.last_gate_idx { - self.circuit.ticks[self.tick_idx].set_gate_attrs(gate_idx, attrs); + if let Some(piece) = self.last_gate_piece { + let piece = self.circuit.ticks[self.tick_idx].set_gate_attrs_for_piece(piece, attrs); + self.last_gate_idx = Some(piece.gate_idx); + self.last_gate_piece = Some(piece); } else { // No gate yet - set tick-level metadata self.circuit.ticks[self.tick_idx].set_attrs(attrs); @@ -1351,6 +2684,14 @@ impl<'a> TickHandle<'a> { self.add_gate(Gate::u(theta.into(), phi.into(), lambda.into(), qubits)) } + /// Place a typed channel operation in this tick. + /// + /// This is for annotated/noisy circuits. It does not use custom-gate + /// metadata; the channel payload is stored directly on the gate. + pub fn channel(&mut self, channel: ChannelExpr) -> &mut Self { + self.add_gate(Gate::channel(channel)) + } + // --- Two-qubit gates --- /// Apply CNOT (CX) gate(s) to one or more qubit pairs. @@ -1591,11 +2932,13 @@ impl<'a> TickHandle<'a> { /// circuit.tick().pz(&[1, 2, 3]); // Multiple qubits /// ``` pub fn pz(mut self, qubits: &[impl Into + Copy]) -> TickPrepHandle<'a> { - let gate_idx = self.add_gate_get_idx(Gate::pz(qubits)); + let gate_piece = self.add_gate_get_piece(Gate::pz(qubits)); + self.last_gate_idx = None; + self.last_gate_piece = None; TickPrepHandle { circuit: self.circuit, tick_idx: self.tick_idx, - gate_idx, + gate_piece, } } @@ -1613,13 +2956,33 @@ impl<'a> TickHandle<'a> { /// circuit.tick().mz(&[0]); // Single qubit /// circuit.tick().mz(&[1, 2, 3]); // Multiple qubits /// ``` - pub fn mz(mut self, qubits: &[impl Into + Copy]) -> TickMeasureHandle<'a> { - let gate_idx = self.add_gate_get_idx(Gate::mz(qubits)); - TickMeasureHandle { - circuit: self.circuit, - tick_idx: self.tick_idx, - gate_idx, + pub fn mz(mut self, qubits: &[impl Into + Copy]) -> Vec { + let mut gate = Gate::mz(qubits); + let mut refs = Vec::with_capacity(qubits.len()); + for &q in qubits { + let tick_idx = self.tick_idx; + let record_idx = self.circuit.next_meas_record; + self.circuit.next_meas_record += 1; + let mr = MeasId(record_idx); + gate.meas_ids.push(mr); + refs.push(TickMeasRef { + tick: tick_idx, + gate_idx: 0, // placeholder, updated below + qubit: q.into(), + record_idx, + meas_id: mr, + }); } + let gate_idx = self.add_gate_get_idx(gate); + self.last_gate_idx = None; + self.last_gate_piece = None; + // Fix up gate_idx in refs (needed because we had to build gate before adding) + refs.into_iter() + .map(|mut r| { + r.gate_idx = gate_idx; + r + }) + .collect() } /// Measure and free qubit(s) (destructive measurement). @@ -1634,13 +2997,32 @@ impl<'a> TickHandle<'a> { /// let mut circuit = TickCircuit::new(); /// circuit.tick().mz_free(&[0, 1]); /// ``` - pub fn mz_free(mut self, qubits: &[impl Into + Copy]) -> TickMeasureHandle<'a> { - let gate_idx = self.add_gate_get_idx(Gate::mz_free(qubits)); - TickMeasureHandle { - circuit: self.circuit, - tick_idx: self.tick_idx, - gate_idx, + pub fn mz_free(mut self, qubits: &[impl Into + Copy]) -> Vec { + let mut gate = Gate::mz_free(qubits); + let mut refs = Vec::with_capacity(qubits.len()); + for &q in qubits { + let tick_idx = self.tick_idx; + let record_idx = self.circuit.next_meas_record; + self.circuit.next_meas_record += 1; + let mr = MeasId(record_idx); + gate.meas_ids.push(mr); + refs.push(TickMeasRef { + tick: tick_idx, + gate_idx: 0, + qubit: q.into(), + record_idx, + meas_id: mr, + }); } + let gate_idx = self.add_gate_get_idx(gate); + self.last_gate_idx = None; + self.last_gate_piece = None; + refs.into_iter() + .map(|mut r| { + r.gate_idx = gate_idx; + r + }) + .collect() } // --- Resource management --- @@ -1718,20 +3100,21 @@ impl<'a> TickHandle<'a> { let qubit_ids: GateQubits = qubits.iter().map(|&q| QubitId::from(q)).collect(); let gate = Gate::new(GateType::Custom, angles.to_vec(), vec![], qubit_ids); - match self.circuit.ticks[self.tick_idx].try_add_gate(gate) { - Ok(idx) => { - self.last_gate_idx = Some(idx); + match self.circuit.ticks[self.tick_idx].try_add_gate_piece(gate) { + Ok(piece) => { // Auto-store _symbol metadata - self.circuit.ticks[self.tick_idx].set_gate_attr( - idx, + let piece = self.circuit.ticks[self.tick_idx].set_gate_attr_for_piece( + piece, "_symbol", Attribute::String(name.to_string()), ); + self.last_gate_idx = Some(piece.gate_idx); + self.last_gate_piece = Some(piece); Ok(self) } Err(mut err) => { - err.tick_idx = Some(self.tick_idx); - Err(CustomGateError::QubitConflict(err)) + err.set_tick_idx(self.tick_idx); + Err(err.into()) } } } @@ -1759,36 +3142,68 @@ impl From<&DagCircuit> for TickCircuit { /// ``` fn from(dag: &DagCircuit) -> Self { let mut tc = TickCircuit::new(); + let mut dag_node_to_record_indices: BTreeMap> = BTreeMap::new(); + let mut next_meas_record = 0usize; for layer in dag.layers() { - // Allocate a new tick for this layer - let tick_idx = { - let handle = tc.tick(); - handle.index() - }; - - // Add all gates in this layer to the tick - if let Some(tick) = tc.get_tick_mut(tick_idx) { - for node_id in layer { - if let Some(gate) = dag.gate(node_id) { - let gate_idx = tick.add_gate(gate.clone()); - - // Copy gate attributes - if let Some(attrs) = dag.gate_attrs(node_id) { - for (key, value) in attrs { - tick.set_gate_attr(gate_idx, key, value.clone()); + let mut layer_ticks = vec![Tick::new()]; + + for node_id in layer { + if let Some(gate) = dag.gate(node_id) { + let mut gate = gate.clone(); + if matches!( + gate.gate_type, + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked + ) { + if gate.meas_ids.is_empty() { + let mut records = Vec::with_capacity(gate.qubits.len()); + for _ in &gate.qubits { + let record_idx = next_meas_record; + next_meas_record += 1; + gate.meas_ids.push(MeasId(record_idx)); + records.push(record_idx); } + dag_node_to_record_indices.insert(node_id, records); + } else { + let records: Vec = + gate.meas_ids.iter().map(|meas_id| meas_id.0).collect(); + if let Some(next) = records.iter().max().map(|record| record + 1) { + next_meas_record = next_meas_record.max(next); + } + dag_node_to_record_indices.insert(node_id, records); } } + + let target_idx = layer_ticks + .iter() + .position(|tick| tick.find_conflicts(&gate.qubits).is_empty()) + .unwrap_or_else(|| { + layer_ticks.push(Tick::new()); + layer_ticks.len() - 1 + }); + let tick = &mut layer_ticks[target_idx]; + // Copy gate attributes + if let Some(attrs) = dag.gate_attrs(node_id) { + let gate_idx = tick + .try_add_gate_preserving_command(gate) + .unwrap_or_else(|err| panic!("{err}")); + tick.set_gate_attrs(gate_idx, attrs.clone()); + tick.merge_compatible_gate_at(gate_idx); + } else { + tick.add_gate(gate); + } } } + + tc.ticks.extend(layer_ticks); } + tc.next_tick = tc.ticks.len(); + tc.next_meas_record = next_meas_record; // Copy circuit-level attributes, restoring tick-level attrs from prefixed keys let tick_attr_prefix = "tick["; for (key, value) in dag.attrs() { if key.starts_with(tick_attr_prefix) { - // Parse tick[N].attr_name format if let Some(rest) = key.strip_prefix(tick_attr_prefix) && let Some(bracket_pos) = rest.find(']') && let Ok(tick_idx) = rest[..bracket_pos].parse::() @@ -1805,10 +3220,62 @@ impl From<&DagCircuit> for TickCircuit { } } + // Transfer annotations, remapping DAG measurement nodes to TickCircuit + // measurement record indices. Tracked Paulis have no measurement + // readout and keep their Pauli role unchanged. + tc.annotations = dag + .annotations() + .iter() + .map(|ann| { + let kind = match &ann.kind { + AnnotationKind::Detector { + measurement_nodes, + coords, + } => AnnotationKind::Detector { + measurement_nodes: remap_dag_measurement_nodes( + &dag_node_to_record_indices, + measurement_nodes, + ), + coords: coords.clone(), + }, + AnnotationKind::Observable { measurement_nodes } => { + AnnotationKind::Observable { + measurement_nodes: remap_dag_measurement_nodes( + &dag_node_to_record_indices, + measurement_nodes, + ), + } + } + AnnotationKind::TrackedPauli => AnnotationKind::TrackedPauli, + }; + PauliAnnotation { + pauli: ann.pauli.clone(), + kind, + label: ann.label.clone(), + } + }) + .collect(); + tc } } +fn remap_dag_measurement_nodes( + dag_node_to_record_indices: &BTreeMap>, + measurement_nodes: &[usize], +) -> Vec { + measurement_nodes + .iter() + .flat_map(|node| { + dag_node_to_record_indices + .get(node) + .unwrap_or_else(|| panic!("annotation references non-measurement DAG node {node}")) + .iter() + .copied() + }) + .collect() +} + impl From for TickCircuit { fn from(dag: DagCircuit) -> Self { TickCircuit::from(&dag) @@ -1840,22 +3307,52 @@ impl From<&TickCircuit> for DagCircuit { // Track the last node for each qubit to connect wires let mut last_node: BTreeMap = BTreeMap::new(); - for (tick_idx, tick) in tc.ticks().iter().enumerate() { - for (gate_idx, gate) in tick.gates().iter().enumerate() { - let node = dag.add_gate(gate.clone()); + // Map measurement_record_index -> dag node for annotation transfer + let mut meas_record_to_node: BTreeMap = BTreeMap::new(); + let mut meas_record_idx = 0usize; + + for (tick_idx, tick) in tc.iter_ticks() { + for batch in tick.iter_gate_batches() { + // DagCircuit stores individual gate applications. Use the + // TickCircuit instance view so qubit/meas-id slicing has one + // implementation. Zero-gate metadata batches do not have gate + // instances, so keep their stored command as a single DAG node. + let split_gates: Vec = if batch.gate_count() == 0 { + vec![batch.as_gate().clone()] + } else { + batch + .iter_gate_instances() + .map(GateInstanceRef::to_gate) + .collect() + }; + + let mut split_nodes = Vec::with_capacity(split_gates.len()); + for split_gate in &split_gates { + let node = dag.add_gate(split_gate.clone()); + split_nodes.push(node); + + // For MZ gates, map each qubit's record to this node + if split_gate.gate_type == GateType::MZ { + for _q in &split_gate.qubits { + meas_record_to_node.insert(meas_record_idx, node); + meas_record_idx += 1; + } + } - // Connect wires from previous gates on the same qubits - for qubit in &gate.qubits { - if let Some(&prev_node) = last_node.get(qubit) { - // Connect previous node to this one on this qubit - let _ = dag.connect(prev_node, node, *qubit); + // Connect wires from previous gates on the same qubits + for qubit in &split_gate.qubits { + if let Some(&prev_node) = last_node.get(qubit) { + let _ = dag.connect(prev_node, node, *qubit); + } + last_node.insert(*qubit, node); } - last_node.insert(*qubit, node); } - // Copy gate attributes - for (key, value) in tick.gate_attrs(gate_idx) { - dag.set_gate_attr(node, key, value.clone()); + // Copy batch-level gate attributes to every split gate. + for (key, value) in batch.attrs() { + for &node in &split_nodes { + dag.set_gate_attr(node, key, value.clone()); + } } } @@ -1871,6 +3368,40 @@ impl From<&TickCircuit> for DagCircuit { dag.set_attr(key.clone(), value.clone()); } + // Transfer annotations, remapping measurement record indices to DAG node indices + for ann in &tc.annotations { + let remapped_kind = match &ann.kind { + AnnotationKind::Detector { + measurement_nodes, + coords, + } => { + let dag_nodes: Vec = measurement_nodes + .iter() + .filter_map(|&rec| meas_record_to_node.get(&rec).copied()) + .collect(); + AnnotationKind::Detector { + measurement_nodes: dag_nodes, + coords: coords.clone(), + } + } + AnnotationKind::Observable { measurement_nodes } => { + let dag_nodes: Vec = measurement_nodes + .iter() + .filter_map(|&rec| meas_record_to_node.get(&rec).copied()) + .collect(); + AnnotationKind::Observable { + measurement_nodes: dag_nodes, + } + } + AnnotationKind::TrackedPauli => AnnotationKind::TrackedPauli, + }; + dag.add_annotation(PauliAnnotation { + pauli: ann.pauli.clone(), + kind: remapped_kind, + label: ann.label.clone(), + }); + } + dag } } @@ -1918,7 +3449,7 @@ mod tests { tc.tick().mz(&[0, 1]); assert_eq!(tc.num_ticks(), 4); - assert_eq!(tc.gate_count(), 4); // 1 bulk prep, 1 H, 1 CX, 1 bulk measure + assert_eq!(tc.gate_count(), 6); // 2 preps, 1 H, 1 CX, 2 measurements // Check tick contents assert_eq!(tc.get_tick(0).unwrap().len(), 1); // One bulk prep gate @@ -1928,192 +3459,1494 @@ mod tests { } #[test] - fn test_meta_on_gates() { + fn test_tick_construction_merges_compatible_gate_batches() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]).h(&[1]).cx(&[(2, 3)]).cx(&[(4, 5)]); + + let tick = tc.get_tick(0).unwrap(); + assert_eq!(tick.len(), 2); // one H batch, one CX batch + assert_eq!(tick.gate_count(), 4); + assert_eq!(tick.gate_batch_count(), 2); + assert_eq!(tc.gate_count(), 4); + assert_eq!(tc.gate_batch_count(), 2); + + assert_eq!(tick.gate_batches()[0].gate_type, GateType::H); + assert_eq!( + tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + assert_eq!(tick.gate_batches()[1].gate_type, GateType::CX); + assert_eq!( + tick.gate_batches()[1].qubits.as_slice(), + &[ + QubitId::from(2), + QubitId::from(3), + QubitId::from(4), + QubitId::from(5) + ] + ); + } + + #[test] + fn test_tick_replace_gate_batch_updates_stored_views() { let mut tc = TickCircuit::new(); + tc.tick().h(&[0]).h(&[1]); + + let tick = tc.get_tick_mut(0).unwrap(); + tick.replace_gate_batch(0, Gate::x(&[0, 1])) + .expect("same support replacement should be valid"); + + assert_eq!(tick.len(), 1); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batches()[0].gate_type, GateType::X); + assert_eq!( + tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + } + #[test] + fn test_tick_replace_gate_batch_preserves_aligned_attrs() { + let mut tc = TickCircuit::new(); tc.tick() - .h(&[0]) - .meta("duration", Attribute::Float(50.0)) - .meta("error_rate", Attribute::Float(0.001)) - .x(&[1]) - .meta("duration", Attribute::Float(50.0)); + .h(&[0, 1]) + .meta("calibration", Attribute::String("old".into())); + + let tick = tc.get_tick_mut(0).unwrap(); + tick.replace_gate_batch(0, Gate::x(&[0, 1])) + .expect("same support replacement should be valid"); + + assert_eq!(tick.len(), 1); + assert_eq!(tick.gate_batches()[0].gate_type, GateType::X); + assert_eq!( + tick.get_gate_attr(0, "calibration"), + Some(&Attribute::String("old".into())) + ); + } + + #[test] + fn test_tick_replace_gate_batch_rejects_overlapping_qubits() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]).x(&[1]); + + let tick = tc.get_tick_mut(0).unwrap(); + let err = tick + .replace_gate_batch(0, Gate::z(&[1])) + .expect_err("replacement overlaps the X command on q1"); + + assert!(matches!(err, TickGateError::QubitConflict(_))); + assert_eq!(tick.gate_batches()[0].gate_type, GateType::H); + assert_eq!(tick.gate_batches()[1].gate_type, GateType::X); + } + + #[test] + fn test_tick_update_gate_batch_keeps_measurement_ids_in_sync() { + let mut tc = TickCircuit::new(); + tc.tick().mz(&[0, 1]); + + let tick = tc.get_tick_mut(0).unwrap(); + tick.update_gate_batch(0, |gate| { + gate.meas_ids[0] = MeasId(10); + gate.meas_ids[1] = MeasId(11); + }) + .expect("measurement id update should be valid"); + + assert_eq!( + tick.gate_batches()[0].meas_ids.as_slice(), + &[MeasId(10), MeasId(11)] + ); + } + + #[test] + fn test_tick_construction_batches_only_same_metadata() { + let mut same = TickCircuit::new(); + { + let mut tick = same.tick(); + tick.h(&[0]) + .meta("calibration", Attribute::String("a".into())); + tick.h(&[1]) + .meta("calibration", Attribute::String("a".into())); + } + + let tick = same.get_tick(0).unwrap(); + assert_eq!(tick.len(), 1); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 1); + assert_eq!( + tick.get_gate_attr(0, "calibration"), + Some(&Attribute::String("a".into())) + ); + + let mut different = TickCircuit::new(); + { + let mut tick = different.tick(); + tick.h(&[0]) + .meta("calibration", Attribute::String("a".into())); + tick.h(&[1]) + .meta("calibration", Attribute::String("b".into())); + } + + let tick = different.get_tick(0).unwrap(); + assert_eq!(tick.len(), 2); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 2); + } + + #[test] + fn test_tick_construction_metadata_applies_to_last_gate_before_batching() { + let mut tc = TickCircuit::new(); + { + let mut tick = tc.tick(); + tick.h(&[0]) + .h(&[1]) + .meta("calibration", Attribute::String("second".into())); + } let tick = tc.get_tick(0).unwrap(); + assert_eq!(tick.len(), 2); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 2); assert_eq!( - tick.get_gate_attr(0, "duration"), - Some(&Attribute::Float(50.0)) + tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0)] ); assert_eq!( - tick.get_gate_attr(0, "error_rate"), - Some(&Attribute::Float(0.001)) + tick.gate_batches()[1].qubits.as_slice(), + &[QubitId::from(1)] ); + assert_eq!(tick.get_gate_attr(0, "calibration"), None); assert_eq!( - tick.get_gate_attr(1, "duration"), - Some(&Attribute::Float(50.0)) + tick.get_gate_attr(1, "calibration"), + Some(&Attribute::String("second".into())) ); } #[test] - fn test_tick_meta() { + fn test_tick_construction_multiple_meta_calls_batch_after_completion() { let mut tc = TickCircuit::new(); + { + let mut tick = tc.tick(); + tick.h(&[0]) + .meta("calibration", Attribute::String("a".into())) + .meta("role", Attribute::String("drive".into())); + tick.h(&[1]) + .meta("calibration", Attribute::String("a".into())) + .meta("role", Attribute::String("drive".into())); + } - // meta() before any gates = tick-level metadata - tc.tick().meta("round", Attribute::Int(0)).h(&[0]); - tc.tick().meta("round", Attribute::Int(1)).cx(&[(0, 1)]); + let tick = tc.get_tick(0).unwrap(); + assert_eq!(tick.len(), 1); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 1); + assert_eq!( + tick.get_gate_attr(0, "calibration"), + Some(&Attribute::String("a".into())) + ); + assert_eq!( + tick.get_gate_attr(0, "role"), + Some(&Attribute::String("drive".into())) + ); + } + + #[test] + fn test_prep_metadata_applies_before_batching() { + let mut tc = TickCircuit::new(); + tc.reserve_ticks(1); + tc.tick_at(0).pz(&[0]); + tc.tick_at(0) + .pz(&[1]) + .meta("calibration", Attribute::String("second".into())); + let tick = tc.get_tick(0).unwrap(); + assert_eq!(tick.len(), 2); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 2); + assert_eq!(tick.get_gate_attr(0, "calibration"), None); assert_eq!( - tc.get_tick(0).unwrap().get_attr("round"), - Some(&Attribute::Int(0)) + tick.get_gate_attr(1, "calibration"), + Some(&Attribute::String("second".into())) ); + } + + #[test] + fn test_tick_construction_preserves_measurement_ids_when_batching() { + let mut tc = TickCircuit::new(); + tc.reserve_ticks(1); + + let refs0 = tc.tick_at(0).mz(&[0]); + let refs1 = tc.tick_at(0).mz(&[1]); + + let tick = tc.get_tick(0).unwrap(); + assert_eq!(tick.len(), 1); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 1); + assert_eq!(refs0[0].gate_idx, 0); + assert_eq!(refs1[0].gate_idx, 0); + assert_eq!(refs0[0].meas_id, MeasId(0)); + assert_eq!(refs1[0].meas_id, MeasId(1)); assert_eq!( - tc.get_tick(1).unwrap().get_attr("round"), - Some(&Attribute::Int(1)) + tick.gate_batches()[0].meas_ids.as_slice(), + &[MeasId(0), MeasId(1)] ); } #[test] - fn test_tick_index() { + fn test_measurement_batching_respects_gate_metadata() { + let mut same = TickCircuit::new(); + same.reserve_ticks(1); + let refs0 = same.tick_at(0).mz(&[0]); + same.get_tick_mut(0).unwrap().set_gate_attr( + refs0[0].gate_idx, + "basis", + Attribute::String("Z".into()), + ); + let refs1 = same.tick_at(0).mz(&[1]); + let tick = same.get_tick_mut(0).unwrap(); + tick.set_gate_attr(refs1[0].gate_idx, "basis", Attribute::String("Z".into())); + tick.merge_compatible_gate_at(refs1[0].gate_idx); + + let tick = same.get_tick(0).unwrap(); + assert_eq!(tick.len(), 1); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 1); + assert_eq!( + tick.gate_batches()[0].meas_ids.as_slice(), + &[MeasId(0), MeasId(1)] + ); + assert_eq!( + tick.get_gate_attr(0, "basis"), + Some(&Attribute::String("Z".into())) + ); + + let mut different = TickCircuit::new(); + different.reserve_ticks(1); + let refs0 = different.tick_at(0).mz(&[0]); + different.get_tick_mut(0).unwrap().set_gate_attr( + refs0[0].gate_idx, + "basis", + Attribute::String("Z".into()), + ); + let refs1 = different.tick_at(0).mz(&[1]); + let tick = different.get_tick_mut(0).unwrap(); + tick.set_gate_attr(refs1[0].gate_idx, "basis", Attribute::String("X".into())); + tick.merge_compatible_gate_at(refs1[0].gate_idx); + + let tick = different.get_tick(0).unwrap(); + assert_eq!(tick.len(), 2); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 2); + assert_eq!(tick.gate_batches()[0].meas_ids.as_slice(), &[MeasId(0)]); + assert_eq!(tick.gate_batches()[1].meas_ids.as_slice(), &[MeasId(1)]); + assert_eq!( + tick.get_gate_attr(0, "basis"), + Some(&Attribute::String("Z".into())) + ); + assert_eq!( + tick.get_gate_attr(1, "basis"), + Some(&Attribute::String("X".into())) + ); + } + + #[test] + fn test_tick_construction_keeps_different_parameters_in_separate_batches() { let mut tc = TickCircuit::new(); + tc.tick() + .rz(Angle64::from_turns(0.25), &[0]) + .rz(Angle64::from_turns(0.5), &[1]); - let t0 = tc.tick(); - assert_eq!(t0.index(), 0); + let tick = tc.get_tick(0).unwrap(); + assert_eq!(tick.len(), 2); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 2); + } + + #[test] + fn test_parameterized_gate_batching_counts_and_round_trip() { + let mut tc1 = TickCircuit::new(); + tc1.tick() + .rz(Angle64::from_turns(0.25), &[0]) + .rz(Angle64::from_turns(0.25), &[1]) + .rz(Angle64::from_turns(0.5), &[2]); + + let tick = tc1.get_tick(0).unwrap(); + assert_eq!(tick.len(), 2); + assert_eq!(tick.gate_count(), 3); + assert_eq!(tick.gate_batch_count(), 2); + assert_eq!(tc1.gate_count(), 3); + assert_eq!(tc1.gate_batch_count(), 2); + + let dag = DagCircuit::from(&tc1); + assert_eq!(dag.gate_count(), 3); + assert_eq!(dag.gate_node_count(), 3); + assert_eq!(dag.gate_type_count(GateType::RZ), 3); + + let tc2 = TickCircuit::from(&dag); + let tick = tc2.get_tick(0).unwrap(); + assert_eq!(tc2.gate_count(), 3); + assert_eq!(tc2.gate_batch_count(), 2); + assert_eq!(tick.len(), 2); + assert_eq!(tick.gate_count(), 3); + assert_eq!(tick.gate_batch_count(), 2); + assert_eq!( + tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + assert_eq!( + tick.gate_batches()[0].angles, + Gate::rz(Angle64::from_turns(0.25), &[0]).angles + ); + assert_eq!( + tick.gate_batches()[1].qubits.as_slice(), + &[QubitId::from(2)] + ); + assert_eq!( + tick.gate_batches()[1].angles, + Gate::rz(Angle64::from_turns(0.5), &[2]).angles + ); + } + + #[test] + fn test_meta_on_gates() { + let mut tc = TickCircuit::new(); + + tc.tick() + .h(&[0]) + .meta("duration", Attribute::Float(50.0)) + .meta("error_rate", Attribute::Float(0.001)) + .x(&[1]) + .meta("duration", Attribute::Float(50.0)); + + let tick = tc.get_tick(0).unwrap(); + assert_eq!( + tick.get_gate_attr(0, "duration"), + Some(&Attribute::Float(50.0)) + ); + assert_eq!( + tick.get_gate_attr(0, "error_rate"), + Some(&Attribute::Float(0.001)) + ); + assert_eq!( + tick.get_gate_attr(1, "duration"), + Some(&Attribute::Float(50.0)) + ); + } + + #[test] + fn test_tick_meta() { + let mut tc = TickCircuit::new(); + + // meta() before any gates = tick-level metadata + tc.tick().meta("round", Attribute::Int(0)).h(&[0]); + tc.tick().meta("round", Attribute::Int(1)).cx(&[(0, 1)]); + + assert_eq!( + tc.get_tick(0).unwrap().get_attr("round"), + Some(&Attribute::Int(0)) + ); + assert_eq!( + tc.get_tick(1).unwrap().get_attr("round"), + Some(&Attribute::Int(1)) + ); + } + + #[test] + fn test_tick_index() { + let mut tc = TickCircuit::new(); + + let t0 = tc.tick(); + assert_eq!(t0.index(), 0); + + let t1 = tc.tick(); + assert_eq!(t1.index(), 1); + + assert_eq!(tc.next_tick_index(), 2); + } + + #[test] + fn test_gates_chain_but_preps_and_meas_break() { + let mut tc = TickCircuit::new(); + + // Regular gates chain within a tick + tc.tick().h(&[0]).x(&[1]).y(&[2]).z(&[3]); + tc.tick().cx(&[(0, 1)]).szz(&[(2, 3)]); + + // But preps and measurements break the chain + tc.tick().pz(&[0]); // breaks chain + tc.tick().mz(&[0]); // breaks chain + + assert_eq!(tc.num_ticks(), 4); + assert_eq!(tc.gate_count(), 8); + } + + #[test] + fn test_prep_and_meas_with_meta() { + let mut tc = TickCircuit::new(); + + // Preps and measurements allow .meta() before breaking + tc.tick() + .pz(&[0]) + .meta("reason", Attribute::String("init".into())); + tc.tick().h(&[0]); + tc.tick().mz(&[0]); + // Attach metadata to the measurement gate directly + tc.get_tick_mut(2) + .unwrap() + .set_gate_attr(0, "basis", Attribute::String("Z".into())); + + assert_eq!(tc.num_ticks(), 3); + + // Check that metadata was attached + assert_eq!( + tc.get_tick(0).unwrap().get_gate_attr(0, "reason"), + Some(&Attribute::String("init".into())) + ); + assert_eq!( + tc.get_tick(2).unwrap().get_gate_attr(0, "basis"), + Some(&Attribute::String("Z".into())) + ); + } + + #[test] + fn test_circuit_meta() { + let mut tc = TickCircuit::new(); + tc.set_meta("name", Attribute::String("bell_state".to_string())); + tc.tick().h(&[0]); + + assert_eq!( + tc.get_meta("name"), + Some(&Attribute::String("bell_state".to_string())) + ); + } + + #[test] + fn test_tick_circuit_to_dag_circuit() { + let mut tc = TickCircuit::new(); + tc.set_meta("circuit_name", Attribute::String("test".to_string())); + + // Build a small circuit + tc.tick().h(&[0]).x(&[1]); // Tick 0: parallel H and X + tc.tick().cx(&[(0, 1)]); // Tick 1: CX + tc.tick().h(&[0]); // Tick 2: H + + let dag = DagCircuit::from(&tc); + + // Check gate counts + assert_eq!(dag.gate_count(), 4); + + // Check wires: H(0)->CX->H(0), X(1)->CX + // So we have 3 wires: H(0)->CX, CX->H(0), X(1)->CX + assert_eq!(dag.wire_count(), 3); + + // Check circuit attributes + assert_eq!( + dag.get_attr("circuit_name"), + Some(&Attribute::String("test".to_string())) + ); + } + + #[test] + fn test_tick_to_dag_splits_batched_standard_two_qubit_cliffords_as_pairs() { + fn add_gate(tc: &mut TickCircuit, gate_type: GateType, pairs: &[(usize, usize)]) { + match gate_type { + GateType::CX => { + tc.tick().cx(pairs); + } + GateType::CY => { + tc.tick().cy(pairs); + } + GateType::CZ => { + tc.tick().cz(pairs); + } + GateType::SXX => { + tc.tick().sxx(pairs); + } + GateType::SXXdg => { + tc.tick().sxxdg(pairs); + } + GateType::SYY => { + tc.tick().syy(pairs); + } + GateType::SYYdg => { + tc.tick().syydg(pairs); + } + GateType::SZZ => { + tc.tick().szz(pairs); + } + GateType::SZZdg => { + tc.tick().szzdg(pairs); + } + GateType::SWAP => { + tc.tick().swap(pairs); + } + _ => unreachable!(), + } + } + + let pair_sets = [ + [(0usize, 1usize), (2usize, 3usize)], + [(4usize, 1usize), (9usize, 2usize)], + ]; + + for gate_type in [ + GateType::CX, + GateType::CY, + GateType::CZ, + GateType::SXX, + GateType::SXXdg, + GateType::SYY, + GateType::SYYdg, + GateType::SZZ, + GateType::SZZdg, + GateType::SWAP, + ] { + for pairs in pair_sets { + let mut tc = TickCircuit::new(); + add_gate(&mut tc, gate_type, &pairs); + + let dag = DagCircuit::from(&tc); + let gates: Vec<_> = dag.iter_gates().map(|(_, gate)| gate).collect(); + assert_eq!(gates.len(), 2, "{gate_type:?} {pairs:?}"); + assert!( + gates.iter().all(|gate| gate.gate_type == gate_type), + "{gate_type:?} {pairs:?}" + ); + assert!( + gates.iter().all(|gate| gate.qubits.len() == 2), + "{gate_type:?} should remain pairwise in the DAG for {pairs:?}" + ); + for (q0, q1) in pairs { + assert!( + gates.iter().any(|gate| gate + .qubits + .iter() + .copied() + .eq([QubitId(q0), QubitId(q1)])), + "{gate_type:?} should preserve pair ({q0}, {q1})" + ); + } + } + } + } + + #[test] + fn test_dag_circuit_to_tick_circuit() { + let mut dag = DagCircuit::new(); + dag.set_attr("version".to_string(), Attribute::Int(1)); + + // Build: H(0) -> CX(0,1) + // X(1) ----^ + let h = dag.add_gate(Gate::h(&[0])); + let x = dag.add_gate(Gate::x(&[1])); + let cx = dag.add_gate(Gate::cx(&[(0, 1)])); + + dag.connect(h, cx, QubitId::from(0)).unwrap(); + dag.connect(x, cx, QubitId::from(1)).unwrap(); + + let tc = TickCircuit::from(&dag); + + // H and X are parallel (layer 0), CX depends on both (layer 1) + assert_eq!(tc.num_ticks(), 2); + + // First tick should have H and X (order may vary) + let tick0 = tc.get_tick(0).unwrap(); + assert_eq!(tick0.gate_batches().len(), 2); + + // Second tick should have CX + let tick1 = tc.get_tick(1).unwrap(); + assert_eq!(tick1.gate_batches().len(), 1); + + // Check circuit attribute + assert_eq!(tc.get_meta("version"), Some(&Attribute::Int(1))); + } + + #[test] + fn test_round_trip_tick_to_dag_to_tick() { + let mut tc1 = TickCircuit::new(); + tc1.tick().h(&[0]); + tc1.tick().cx(&[(0, 1)]); + tc1.tick().h(&[1]); + + // Convert to DAG and back + let dag = DagCircuit::from(&tc1); + let tc2 = TickCircuit::from(&dag); + + // Should have same structure + assert_eq!(tc1.num_ticks(), tc2.num_ticks()); + for i in 0..tc1.num_ticks() { + assert_eq!( + tc1.get_tick(i).unwrap().gate_batches().len(), + tc2.get_tick(i).unwrap().gate_batches().len() + ); + } + } + + #[test] + fn test_tick_dag_round_trip_preserves_counts_and_metadata_batches() { + let mut tc1 = TickCircuit::new(); + tc1.set_meta("name", Attribute::String("metadata-heavy".into())); + tc1.tick() + .meta("round", Attribute::Int(0)) + .h(&[0, 1]) + .meta("calibration", Attribute::String("h-cal".into())); + tc1.tick() + .meta("round", Attribute::Int(1)) + .cx(&[(0, 2), (1, 3)]) + .meta("calibration", Attribute::String("cx-cal".into())); + let ms = tc1.tick().mz(&[0, 1]); + + assert_eq!(tc1.num_ticks(), 3); + assert_eq!(tc1.gate_count(), 6); + assert_eq!(tc1.gate_batch_count(), 3); + assert_eq!(ms[0].meas_id, MeasId(0)); + assert_eq!(ms[1].meas_id, MeasId(1)); + + let dag = DagCircuit::from(&tc1); + assert_eq!(dag.gate_count(), 6); + assert_eq!(dag.gate_node_count(), 6); + assert_eq!(dag.gate_type_count(GateType::H), 2); + assert_eq!(dag.gate_type_count(GateType::CX), 2); + assert_eq!(dag.gate_type_count(GateType::MZ), 2); + + for (node, gate) in dag.iter_gates() { + match gate.gate_type { + GateType::H => assert_eq!( + dag.get_gate_attr(node, "calibration"), + Some(&Attribute::String("h-cal".into())) + ), + GateType::CX => assert_eq!( + dag.get_gate_attr(node, "calibration"), + Some(&Attribute::String("cx-cal".into())) + ), + _ => {} + } + } + + let tc2 = TickCircuit::from(&dag); + assert_eq!(tc2.num_ticks(), 3); + assert_eq!(tc2.gate_count(), 6); + assert_eq!(tc2.gate_batch_count(), 3); + assert_eq!( + tc2.get_meta("name"), + Some(&Attribute::String("metadata-heavy".into())) + ); + assert_eq!( + tc2.get_tick(0).unwrap().get_attr("round"), + Some(&Attribute::Int(0)) + ); + assert_eq!( + tc2.get_tick(1).unwrap().get_attr("round"), + Some(&Attribute::Int(1)) + ); + + let tick0 = tc2.get_tick(0).unwrap(); + assert_eq!(tick0.len(), 1); + assert_eq!(tick0.gate_count(), 2); + assert_eq!(tick0.gate_batch_count(), 1); + assert_eq!(tick0.gate_batches()[0].gate_type, GateType::H); + assert_eq!( + tick0.get_gate_attr(0, "calibration"), + Some(&Attribute::String("h-cal".into())) + ); + + let tick1 = tc2.get_tick(1).unwrap(); + assert_eq!(tick1.len(), 1); + assert_eq!(tick1.gate_count(), 2); + assert_eq!(tick1.gate_batch_count(), 1); + assert_eq!(tick1.gate_batches()[0].gate_type, GateType::CX); + assert_eq!( + tick1.get_gate_attr(0, "calibration"), + Some(&Attribute::String("cx-cal".into())) + ); + + let tick2 = tc2.get_tick(2).unwrap(); + assert_eq!(tick2.len(), 1); + assert_eq!(tick2.gate_count(), 2); + assert_eq!(tick2.gate_batch_count(), 1); + assert_eq!(tick2.gate_batches()[0].gate_type, GateType::MZ); + assert_eq!( + tick2.gate_batches()[0].meas_ids.as_slice(), + &[MeasId(0), MeasId(1)] + ); + } + + #[test] + fn test_dag_to_tick_preserves_distinct_metadata_batches() { + let mut dag = DagCircuit::new(); + let h0 = dag.add_gate(Gate::h(&[0])); + dag.set_gate_attr(h0, "calibration", Attribute::String("a".into())); + let h1 = dag.add_gate(Gate::h(&[1])); + dag.set_gate_attr(h1, "calibration", Attribute::String("b".into())); + let h2 = dag.add_gate(Gate::h(&[2])); + dag.set_gate_attr(h2, "calibration", Attribute::String("a".into())); + + let tc = TickCircuit::from(&dag); + assert_eq!(tc.gate_count(), 3); + assert_eq!(tc.gate_batch_count(), 2); + + let tick = tc.get_tick(0).unwrap(); + assert_eq!(tick.len(), 2); + assert_eq!(tick.gate_count(), 3); + assert_eq!(tick.gate_batch_count(), 2); + assert_eq!( + tick.get_gate_attr(0, "calibration"), + Some(&Attribute::String("a".into())) + ); + assert_eq!( + tick.get_gate_attr(1, "calibration"), + Some(&Attribute::String("b".into())) + ); + assert_eq!( + tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0), QubitId::from(2)] + ); + assert_eq!( + tick.gate_batches()[1].qubits.as_slice(), + &[QubitId::from(1)] + ); + } + + #[test] + fn test_batched_measurement_metadata_round_trip() { + let mut tc1 = TickCircuit::new(); + let ms = tc1.tick().mz(&[0, 1]); + tc1.get_tick_mut(0).unwrap().set_gate_attr( + ms[0].gate_idx, + "basis", + Attribute::String("Z".into()), + ); + + let dag = DagCircuit::from(&tc1); + assert_eq!(dag.gate_count(), 2); + assert_eq!(dag.gate_node_count(), 2); + for (node, gate) in dag.iter_gates() { + assert_eq!(gate.gate_type, GateType::MZ); + assert_eq!( + dag.get_gate_attr(node, "basis"), + Some(&Attribute::String("Z".into())) + ); + } + + let tc2 = TickCircuit::from(&dag); + assert_eq!(tc2.gate_count(), 2); + assert_eq!(tc2.gate_batch_count(), 1); + + let tick = tc2.get_tick(0).unwrap(); + assert_eq!(tick.len(), 1); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 1); + assert_eq!(tick.gate_batches()[0].gate_type, GateType::MZ); + assert_eq!( + tick.gate_batches()[0].meas_ids.as_slice(), + &[MeasId(0), MeasId(1)] + ); + assert_eq!( + tick.get_gate_attr(0, "basis"), + Some(&Attribute::String("Z".into())) + ); + } + + #[test] + fn test_channel_gate_counts_as_single_operation_through_round_trip() { + let mut tc1 = TickCircuit::new(); + tc1.tick().channel( + pecos_core::channel::Depolarizing(0.1, 0) & pecos_core::channel::Dephasing(0.2, 1), + ); + + let tick = tc1.get_tick(0).unwrap(); + assert_eq!(tick.len(), 1); + assert_eq!(tick.gate_count(), 1); + assert_eq!(tick.gate_batch_count(), 1); + assert_eq!(tc1.gate_count(), 1); + assert_eq!(tc1.gate_batch_count(), 1); + assert_eq!( + tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + + let dag = DagCircuit::from(&tc1); + assert_eq!(dag.gate_count(), 1); + assert_eq!(dag.gate_node_count(), 1); + let (_, gate) = dag.iter_gates().next().unwrap(); + assert!(gate.is_channel()); + assert_eq!( + gate.qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + + let tc2 = TickCircuit::from(&dag); + assert_eq!(tc2.gate_count(), 1); + assert_eq!(tc2.gate_batch_count(), 1); + let gate = &tc2.get_tick(0).unwrap().gate_batches()[0]; + assert!(gate.is_channel()); + assert_eq!( + gate.qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + } + + #[test] + fn test_batched_measurement_annotation_records_round_trip() { + let mut tc1 = TickCircuit::new(); + let ms = tc1.tick().mz(&[0, 1]); + tc1.detector_labeled("det01", &ms); + tc1.observable_labeled("obs1", &[ms[1]]); + + let dag = DagCircuit::from(&tc1); + let tc2 = TickCircuit::from(&dag); + + assert_eq!(tc2.num_measurements(), 2); + assert_eq!(tc2.annotations().len(), 2); + match &tc2.annotations()[0].kind { + AnnotationKind::Detector { + measurement_nodes, .. + } => assert_eq!(measurement_nodes.as_slice(), &[0, 1]), + other => panic!("expected detector annotation, got {other:?}"), + } + match &tc2.annotations()[1].kind { + AnnotationKind::Observable { measurement_nodes } => { + assert_eq!(measurement_nodes.as_slice(), &[1]); + } + other => panic!("expected observable annotation, got {other:?}"), + } + + let tick = tc2.get_tick(0).unwrap(); + assert_eq!( + tick.gate_batches()[0].meas_ids.as_slice(), + &[MeasId(0), MeasId(1)] + ); + } + + #[test] + fn test_dag_batched_measurement_node_annotation_expands_to_tick_records() { + let mut dag = DagCircuit::new(); + let node = dag.add_gate(Gate::mz(&[0, 1])); + dag.detector_labeled("batched-detector", &[node]); + + let tc = TickCircuit::from(&dag); + assert_eq!(tc.num_measurements(), 2); + assert_eq!(tc.annotations().len(), 1); + match &tc.annotations()[0].kind { + AnnotationKind::Detector { + measurement_nodes, .. + } => assert_eq!(measurement_nodes.as_slice(), &[0, 1]), + other => panic!("expected detector annotation, got {other:?}"), + } + + let tick = tc.get_tick(0).unwrap(); + assert_eq!(tick.len(), 1); + assert_eq!(tick.gate_batches()[0].gate_type, GateType::MZ); + assert_eq!( + tick.gate_batches()[0].meas_ids.as_slice(), + &[MeasId(0), MeasId(1)] + ); + } + + #[test] + fn test_dag_to_tick_preserves_existing_measurement_ids_and_advances_counter() { + let mut dag = DagCircuit::new(); + let mut gate = Gate::mz(&[0]); + gate.meas_ids.push(MeasId(5)); + let node = dag.add_gate(gate); + dag.observable_labeled("obs5", &[node]); + + let mut tc = TickCircuit::from(&dag); + assert_eq!(tc.num_measurements(), 6); + let tick = tc.get_tick(0).unwrap(); + assert_eq!(tick.gate_batches()[0].meas_ids.as_slice(), &[MeasId(5)]); + match &tc.annotations()[0].kind { + AnnotationKind::Observable { measurement_nodes } => { + assert_eq!(measurement_nodes.as_slice(), &[5]); + } + other => panic!("expected observable annotation, got {other:?}"), + } + + let next = tc.tick().mz(&[1]); + assert_eq!(next[0].record_idx, 6); + assert_eq!(next[0].meas_id, MeasId(6)); + assert_eq!(tc.num_measurements(), 7); + } - let t1 = tc.tick(); - assert_eq!(t1.index(), 1); + #[test] + #[should_panic(expected = "annotation references non-measurement DAG node")] + fn test_dag_to_tick_rejects_annotation_referencing_non_measurement_node() { + let mut dag = DagCircuit::new(); + let h = dag.add_gate(Gate::h(&[0])); + dag.detector_labeled("not-a-measurement", &[h]); - assert_eq!(tc.next_tick_index(), 2); + let _ = TickCircuit::from(&dag); } #[test] - fn test_gates_chain_but_preps_and_meas_break() { - let mut tc = TickCircuit::new(); + fn test_detector_observable_and_tracked_pauli_remain_distinct_after_round_trip() { + use pecos_core::pauli::{X, Z}; - // Regular gates chain within a tick - tc.tick().h(&[0]).x(&[1]).y(&[2]).z(&[3]); - tc.tick().cx(&[(0, 1)]).szz(&[(2, 3)]); + let mut tc1 = TickCircuit::new(); + tc1.tick().pz(&[0, 1, 2]); + let ms = tc1.tick().mz(&[0, 1]); + tc1.detector_labeled("detector", &[ms[0]]); + tc1.observable_labeled("observable", &[ms[1]]); + tc1.tracked_pauli_labeled("tracked", X(0) & Z(2)); + + let tc2 = TickCircuit::from(&DagCircuit::from(&tc1)); + assert_eq!(tc2.annotations().len(), 3); + + assert_eq!(tc2.annotations()[0].label.as_deref(), Some("detector")); + match &tc2.annotations()[0].kind { + AnnotationKind::Detector { + measurement_nodes, .. + } => assert_eq!(measurement_nodes.as_slice(), &[0]), + other => panic!("expected detector annotation, got {other:?}"), + } - // But preps and measurements break the chain - tc.tick().pz(&[0]); // breaks chain - tc.tick().mz(&[0]); // breaks chain + assert_eq!(tc2.annotations()[1].label.as_deref(), Some("observable")); + match &tc2.annotations()[1].kind { + AnnotationKind::Observable { measurement_nodes } => { + assert_eq!(measurement_nodes.as_slice(), &[1]); + } + other => panic!("expected observable annotation, got {other:?}"), + } - assert_eq!(tc.num_ticks(), 4); - assert_eq!(tc.gate_count(), 8); + assert_eq!(tc2.annotations()[2].label.as_deref(), Some("tracked")); + assert!(matches!( + tc2.annotations()[2].kind, + AnnotationKind::TrackedPauli + )); + assert_eq!(tc2.annotations()[2].pauli, X(0) & Z(2)); } #[test] - fn test_prep_and_meas_with_meta() { - let mut tc = TickCircuit::new(); + fn test_small_pseudorandom_tick_dag_round_trip_invariants() { + fn assert_no_tick_overlaps(circuit: &TickCircuit) { + for (tick_idx, tick) in circuit.iter_ticks() { + let mut active = BTreeSet::new(); + for gate in tick.gate_batches() { + gate.validate() + .unwrap_or_else(|err| panic!("invalid gate in tick {tick_idx}: {err}")); + for &qubit in &gate.qubits { + assert!( + active.insert(qubit), + "qubit {qubit:?} appears more than once in tick {tick_idx}" + ); + } + } + } + } - // Preps and measurements allow .meta() before breaking - tc.tick() - .pz(&[0]) - .meta("reason", Attribute::String("init".into())); - tc.tick().h(&[0]); - tc.tick() - .mz(&[0]) - .meta("basis", Attribute::String("Z".into())); + fn measurement_ids(circuit: &TickCircuit) -> Vec { + circuit + .iter_gate_batches() + .flat_map(|gate| gate.as_gate().meas_ids.iter().copied()) + .collect() + } - assert_eq!(tc.num_ticks(), 3); + let mut state = 0x5eed_u64; + for case_idx in 0..16 { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1); + let base = ((state >> 32) as usize % 4) * 10; + + let mut tc1 = TickCircuit::new(); + tc1.tick() + .meta("case", Attribute::Int(case_idx)) + .h(&[base, base + 1]) + .meta("role", Attribute::String("prepare".into())); + + if state & 1 == 0 { + tc1.tick() + .cx(&[(base, base + 2), (base + 1, base + 3)]) + .meta("role", Attribute::String("entangle".into())); + } else { + tc1.tick() + .rz(Angle64::from_turns(0.25), &[base]) + .rz(Angle64::from_turns(0.25), &[base + 1]) + .rz(Angle64::from_turns(0.5), &[base + 2]); + } - // Check that metadata was attached - assert_eq!( - tc.get_tick(0).unwrap().get_gate_attr(0, "reason"), - Some(&Attribute::String("init".into())) - ); - assert_eq!( - tc.get_tick(2).unwrap().get_gate_attr(0, "basis"), - Some(&Attribute::String("Z".into())) - ); + let ms = tc1.tick().mz(&[base, base + 1]); + if case_idx % 2 == 0 { + tc1.detector_labeled("det", &ms); + } else { + tc1.observable_labeled("obs", &ms); + } + + let tc2 = TickCircuit::from(&DagCircuit::from(&tc1)); + assert_eq!(tc2.gate_count(), tc1.gate_count()); + assert_eq!(tc2.num_measurements(), tc1.num_measurements()); + assert_eq!(tc2.gate_counts_by_type(), tc1.gate_counts_by_type()); + assert_eq!(measurement_ids(&tc2), measurement_ids(&tc1)); + assert_eq!(tc2.annotations().len(), tc1.annotations().len()); + assert_no_tick_overlaps(&tc2); + } } #[test] - fn test_circuit_meta() { - let mut tc = TickCircuit::new(); - tc.set_meta("name", Attribute::String("bell_state".to_string())); - tc.tick().h(&[0]); + fn test_pseudorandom_round_trip_preserves_measurement_annotation_details() { + use pecos_core::pauli::{X, Y, Z}; + + fn annotation_by_label<'a>(circuit: &'a TickCircuit, label: &str) -> &'a PauliAnnotation { + circuit + .annotations() + .iter() + .find(|ann| ann.label.as_deref() == Some(label)) + .unwrap_or_else(|| panic!("missing annotation {label}")) + } - assert_eq!( - tc.get_meta("name"), - Some(&Attribute::String("bell_state".to_string())) - ); - } + let mut state = 0xaced_u64; + for case_idx in 0..12 { + state = state + .wrapping_mul(2_862_933_555_777_941_757) + .wrapping_add(3_037_000_493); + let base = 20 * case_idx; + + let mut tc1 = TickCircuit::new(); + tc1.tick().h(&[base, base + 1]).cx(&[(base + 2, base + 3)]); + if state & 1 == 0 { + tc1.tick().cx(&[(base, base + 2), (base + 1, base + 3)]); + } else { + tc1.tick() + .rz(Angle64::from_turns(0.25), &[base]) + .rz(Angle64::from_turns(0.25), &[base + 1]); + } - #[test] - fn test_tick_circuit_to_dag_circuit() { - let mut tc = TickCircuit::new(); - tc.set_meta("circuit_name", Attribute::String("test".to_string())); + let measurements = tc1.tick().mz(&[base, base + 1, base + 2]); + let detector_records = if state & 2 == 0 { + vec![measurements[0], measurements[2]] + } else { + vec![measurements[1]] + }; + let observable_records = if state & 4 == 0 { + vec![measurements[1]] + } else { + vec![measurements[0], measurements[2]] + }; + let tracked = if state & 8 == 0 { + X(base) & Z(base + 3) + } else { + Y(base + 3) + }; - // Build a small circuit - tc.tick().h(&[0]).x(&[1]); // Tick 0: parallel H and X - tc.tick().cx(&[(0, 1)]); // Tick 1: CX - tc.tick().h(&[0]); // Tick 2: H + tc1.detector_labeled(&format!("det-{case_idx}"), &detector_records); + tc1.observable_labeled(&format!("obs-{case_idx}"), &observable_records); + tc1.tracked_pauli_labeled(&format!("track-{case_idx}"), tracked.clone()); - let dag = DagCircuit::from(&tc); + let tc2 = TickCircuit::from(&DagCircuit::from(&tc1)); + assert_eq!(tc2.gate_count(), tc1.gate_count(), "case {case_idx}"); + assert_eq!( + tc2.num_measurements(), + tc1.num_measurements(), + "case {case_idx}" + ); + assert_eq!(tc2.annotations().len(), 3, "case {case_idx}"); + + let det = annotation_by_label(&tc2, &format!("det-{case_idx}")); + match &det.kind { + AnnotationKind::Detector { + measurement_nodes, .. + } => assert_eq!( + measurement_nodes, + &detector_records + .iter() + .map(|m| m.record_idx) + .collect::>(), + "case {case_idx}" + ), + other => panic!("expected detector annotation, got {other:?}"), + } - // Check gate counts - assert_eq!(dag.gate_count(), 4); + let obs = annotation_by_label(&tc2, &format!("obs-{case_idx}")); + match &obs.kind { + AnnotationKind::Observable { measurement_nodes } => assert_eq!( + measurement_nodes, + &observable_records + .iter() + .map(|m| m.record_idx) + .collect::>(), + "case {case_idx}" + ), + other => panic!("expected observable annotation, got {other:?}"), + } - // Check wires: H(0)->CX->H(0), X(1)->CX - // So we have 3 wires: H(0)->CX, CX->H(0), X(1)->CX - assert_eq!(dag.wire_count(), 3); + let track = annotation_by_label(&tc2, &format!("track-{case_idx}")); + assert!(matches!(track.kind, AnnotationKind::TrackedPauli)); + assert_eq!(track.pauli, tracked, "case {case_idx}"); + } + } - // Check circuit attributes + #[test] + fn test_all_standard_gate_families_round_trip_through_dag() { + use pecos_core::pauli::{X, Z}; + + fn channel_payloads(circuit: &TickCircuit) -> Vec { + circuit + .iter_gate_batches() + .filter_map(|gate| gate.channel.clone()) + .collect() + } + + fn nonzero_gate_counts( + circuit: &TickCircuit, + ) -> std::collections::BTreeMap { + circuit + .gate_counts_by_type() + .into_iter() + .filter(|(_, count)| *count > 0) + .collect() + } + + let mut tc1 = TickCircuit::new(); + tc1.tick() + .x(&[0]) + .y(&[1]) + .z(&[2]) + .h(&[3]) + .sx(&[4]) + .sxdg(&[5]) + .sy(&[6]) + .sydg(&[7]) + .sz(&[8]) + .szdg(&[9]) + .f(&[10]) + .fdg(&[11]) + .iden(&[12]); + tc1.tick() + .cx(&[(20, 21)]) + .cy(&[(22, 23)]) + .cz(&[(24, 25)]) + .sxx(&[(26, 27)]) + .sxxdg(&[(28, 29)]) + .syy(&[(30, 31)]) + .syydg(&[(32, 33)]) + .szz(&[(34, 35)]) + .szzdg(&[(36, 37)]) + .swap(&[(38, 39)]); + tc1.tick() + .rx(Angle64::from_turns(0.125), &[40]) + .ry(Angle64::from_turns(0.25), &[41]) + .rz(Angle64::from_turns(0.375), &[42]) + .r1xy(Angle64::from_turns(0.125), Angle64::from_turns(0.25), &[43]) + .u( + Angle64::from_turns(0.125), + Angle64::from_turns(0.25), + Angle64::from_turns(0.375), + &[44], + ); + tc1.tick() + .channel(pecos_core::channel::Depolarizing(0.01, 50)); + tc1.tick() + .channel(pecos_core::channel::Depolarizing2(0.02, 51, 52)); + tc1.tick().idle(3u64, &[60]); + tc1.tick().pz(&[70, 71]); + let ms = tc1.tick().mz(&[70, 71]); + tc1.detector_labeled("det-all-gates", &[ms[0]]); + tc1.observable_labeled("obs-all-gates", &[ms[1]]); + tc1.tracked_pauli_labeled("tracked-all-gates", X(70) & Z(71)); + + let tc2 = TickCircuit::from(&DagCircuit::from(&tc1)); + + assert_eq!(tc2.gate_count(), tc1.gate_count()); + assert_eq!(tc2.num_measurements(), tc1.num_measurements()); + assert_eq!(nonzero_gate_counts(&tc2), nonzero_gate_counts(&tc1)); + assert_eq!(channel_payloads(&tc2), channel_payloads(&tc1)); + assert_eq!(tc2.annotations().len(), tc1.annotations().len()); assert_eq!( - dag.get_attr("circuit_name"), - Some(&Attribute::String("test".to_string())) + tc2.annotations() + .iter() + .map(|ann| ann.label.as_deref()) + .collect::>(), + tc1.annotations() + .iter() + .map(|ann| ann.label.as_deref()) + .collect::>() ); + assert!(matches!( + tc2.annotations()[0].kind, + AnnotationKind::Detector { .. } + )); + assert!(matches!( + tc2.annotations()[1].kind, + AnnotationKind::Observable { .. } + )); + assert!(matches!( + tc2.annotations()[2].kind, + AnnotationKind::TrackedPauli + )); + assert!(tc2.has_channel_operations()); } #[test] - fn test_dag_circuit_to_tick_circuit() { - let mut dag = DagCircuit::new(); - dag.set_attr("version".to_string(), Attribute::Int(1)); + fn test_seeded_mixed_standard_gate_round_trip_preserves_metadata_annotations_and_batches() { + use pecos_core::pauli::{X, Y, Z}; - // Build: H(0) -> CX(0,1) - // X(1) ----^ - let h = dag.add_gate(Gate::h(&[0])); - let x = dag.add_gate(Gate::x(&[1])); - let cx = dag.add_gate(Gate::cx(&[(0, 1)])); + fn apply_single(tick: &mut TickHandle<'_>, gate_type: GateType, qubit: usize) { + match gate_type { + GateType::X => { + tick.x(&[qubit]); + } + GateType::Y => { + tick.y(&[qubit]); + } + GateType::Z => { + tick.z(&[qubit]); + } + GateType::H => { + tick.h(&[qubit]); + } + GateType::SZ => { + tick.sz(&[qubit]); + } + GateType::SZdg => { + tick.szdg(&[qubit]); + } + GateType::SX => { + tick.sx(&[qubit]); + } + GateType::SXdg => { + tick.sxdg(&[qubit]); + } + GateType::SY => { + tick.sy(&[qubit]); + } + GateType::SYdg => { + tick.sydg(&[qubit]); + } + GateType::F => { + tick.f(&[qubit]); + } + GateType::Fdg => { + tick.fdg(&[qubit]); + } + other => panic!("unexpected single-qubit gate {other:?}"), + } + } - dag.connect(h, cx, QubitId::from(0)).unwrap(); - dag.connect(x, cx, QubitId::from(1)).unwrap(); + fn apply_pair(tick: &mut TickHandle<'_>, gate_type: GateType, a: usize, b: usize) { + match gate_type { + GateType::CX => { + tick.cx(&[(a, b)]); + } + GateType::CY => { + tick.cy(&[(a, b)]); + } + GateType::CZ => { + tick.cz(&[(a, b)]); + } + GateType::SXX => { + tick.sxx(&[(a, b)]); + } + GateType::SXXdg => { + tick.sxxdg(&[(a, b)]); + } + GateType::SYY => { + tick.syy(&[(a, b)]); + } + GateType::SYYdg => { + tick.syydg(&[(a, b)]); + } + GateType::SZZ => { + tick.szz(&[(a, b)]); + } + GateType::SZZdg => { + tick.szzdg(&[(a, b)]); + } + GateType::SWAP => { + tick.swap(&[(a, b)]); + } + other => panic!("unexpected two-qubit gate {other:?}"), + } + } - let tc = TickCircuit::from(&dag); + fn nonzero_gate_counts( + circuit: &TickCircuit, + ) -> std::collections::BTreeMap { + circuit + .gate_counts_by_type() + .into_iter() + .filter(|(_, count)| *count > 0) + .collect() + } - // H and X are parallel (layer 0), CX depends on both (layer 1) - assert_eq!(tc.num_ticks(), 2); + fn choose_gate(gates: &[GateType], state: u64, shift: u32) -> GateType { + let len = u64::try_from(gates.len()).unwrap(); + let idx = usize::try_from((state >> shift) % len).unwrap(); + gates[idx] + } - // First tick should have H and X (order may vary) - let tick0 = tc.get_tick(0).unwrap(); - assert_eq!(tick0.gates().len(), 2); + const ONE_Q: &[GateType] = &[ + GateType::X, + GateType::Y, + GateType::Z, + GateType::H, + GateType::SZ, + GateType::SZdg, + GateType::SX, + GateType::SXdg, + GateType::SY, + GateType::SYdg, + GateType::F, + GateType::Fdg, + ]; + const TWO_Q: &[GateType] = &[ + GateType::CX, + GateType::CY, + GateType::CZ, + GateType::SXX, + GateType::SXXdg, + GateType::SYY, + GateType::SYYdg, + GateType::SZZ, + GateType::SZZdg, + GateType::SWAP, + ]; + + let mut state = 0xdecaf_bad5eed_u64; + for case_idx in 0..10usize { + state = state + .wrapping_mul(6_364_136_223_846_793_005) + .wrapping_add(1_442_695_040_888_963_407); + let base = 100 * case_idx; + let one_a = choose_gate(ONE_Q, state, 0); + let one_b = choose_gate(ONE_Q, state, 8); + let two = choose_gate(TWO_Q, state, 16); + let rz_bucket = u8::try_from((state >> 24) & 3).unwrap(); + let rz_turns = f64::from(rz_bucket + 1) / 8.0; + + let mut tc1 = TickCircuit::new(); + tc1.set_meta("case", Attribute::Int(i64::try_from(case_idx).unwrap())); + { + let mut tick = tc1.tick(); + tick.meta("round", Attribute::Int(0)) + .meta("kind", Attribute::String("single".into())); + apply_single(&mut tick, one_a, base); + apply_single(&mut tick, one_b, base + 1); + } + { + let mut tick = tc1.tick(); + tick.meta("round", Attribute::Int(1)) + .meta("kind", Attribute::String("mixed".into())); + apply_pair(&mut tick, two, base + 2, base + 3); + tick.rz(Angle64::from_turns(rz_turns), &[base + 4]) + .rx(Angle64::from_turns(0.25), &[base + 5]); + } + tc1.tick() + .meta("round", Attribute::Int(2)) + .channel(pecos_core::channel::Depolarizing(0.01, base + 6)); + tc1.tick().pz(&[base, base + 1, base + 2]); + let measurements = tc1.tick().mz(&[base, base + 1, base + 2]); + tc1.detector_labeled( + &format!("det-{case_idx}"), + &[measurements[0], measurements[2]], + ); + tc1.observable_labeled(&format!("obs-{case_idx}"), &[measurements[1]]); + let tracked = if state & (1 << 32) == 0 { + X(base) & Z(base + 3) + } else { + Y(base + 2) + }; + tc1.tracked_pauli_labeled(&format!("tracked-{case_idx}"), tracked.clone()); - // Second tick should have CX - let tick1 = tc.get_tick(1).unwrap(); - assert_eq!(tick1.gates().len(), 1); + let tc2 = TickCircuit::from(&DagCircuit::from(&tc1)); - // Check circuit attribute - assert_eq!(tc.get_meta("version"), Some(&Attribute::Int(1))); + assert_eq!( + tc2.get_meta("case"), + tc1.get_meta("case"), + "case {case_idx}" + ); + assert_eq!(tc2.gate_count(), tc1.gate_count(), "case {case_idx}"); + assert_eq!( + tc2.num_measurements(), + tc1.num_measurements(), + "case {case_idx}" + ); + assert_eq!( + nonzero_gate_counts(&tc2), + nonzero_gate_counts(&tc1), + "case {case_idx}" + ); + assert_eq!(tc2.annotations().len(), 3, "case {case_idx}"); + assert_eq!( + tc2.annotations() + .iter() + .map(|ann| ann.label.as_deref()) + .collect::>(), + tc1.annotations() + .iter() + .map(|ann| ann.label.as_deref()) + .collect::>(), + "case {case_idx}" + ); + assert!(matches!( + tc2.annotations()[0].kind, + AnnotationKind::Detector { .. } + )); + assert!(matches!( + tc2.annotations()[1].kind, + AnnotationKind::Observable { .. } + )); + assert!(matches!( + tc2.annotations()[2].kind, + AnnotationKind::TrackedPauli + )); + assert_eq!(tc2.annotations()[2].pauli, tracked, "case {case_idx}"); + assert!(tc2.has_channel_operations(), "case {case_idx}"); + } } #[test] - fn test_round_trip_tick_to_dag_to_tick() { - let mut tc1 = TickCircuit::new(); - tc1.tick().h(&[0]); - tc1.tick().cx(&[(0, 1)]); - tc1.tick().h(&[1]); - - // Convert to DAG and back - let dag = DagCircuit::from(&tc1); - let tc2 = TickCircuit::from(&dag); + fn test_batching_invariants_cover_parameters_channels_and_measurements() { + let same_rz = Gate::rz(Angle64::from_turns(0.25), &[0]); + let same_rz_disjoint = Gate::rz(Angle64::from_turns(0.25), &[1]); + let different_rz = Gate::rz(Angle64::from_turns(0.5), &[2]); + assert!(same_rz.can_batch_with(&same_rz_disjoint)); + assert!(!same_rz.can_batch_with(&different_rz)); + + let channel0 = Gate::channel(pecos_core::channel::Depolarizing(0.01, 10)); + let channel1 = Gate::channel(pecos_core::channel::Depolarizing(0.01, 11)); + assert!(!channel0.can_batch_with(&channel1)); + + let mut tick = Tick::new(); + tick.add_gate(same_rz); + tick.add_gate(same_rz_disjoint); + tick.merge_compatible_gate_at(1); + tick.add_gate(different_rz); + tick.add_gate(channel0); + tick.add_gate(channel1); + + assert_eq!(tick.gate_count(), 5); + assert_eq!(tick.gate_batch_count(), 4); + assert_eq!( + tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + assert!(tick.gate_batches()[2].is_channel()); + assert!(tick.gate_batches()[3].is_channel()); + + let mut meas = TickCircuit::new(); + meas.reserve_ticks(1); + let refs0 = meas.tick_at(0).mz(&[0, 1]); + meas.get_tick_mut(0).unwrap().set_gate_attr( + refs0[0].gate_idx, + "readout_family", + Attribute::String("fast".into()), + ); + let refs2 = meas.tick_at(0).mz(&[2]); + let tick = meas.get_tick_mut(0).unwrap(); + tick.set_gate_attr( + refs2[0].gate_idx, + "readout_family", + Attribute::String("slow".into()), + ); + tick.merge_compatible_gate_at(refs2[0].gate_idx); - // Should have same structure - assert_eq!(tc1.num_ticks(), tc2.num_ticks()); - for i in 0..tc1.num_ticks() { - assert_eq!( - tc1.get_tick(i).unwrap().gates().len(), - tc2.get_tick(i).unwrap().gates().len() - ); - } + let tick = meas.get_tick(0).unwrap(); + assert_eq!(tick.len(), 2); + assert_eq!(tick.gate_count(), 3); + assert_eq!(tick.gate_batch_count(), 2); + assert_eq!( + tick.gate_batches()[0].meas_ids.as_slice(), + &[MeasId(0), MeasId(1)] + ); + assert_eq!(tick.gate_batches()[1].meas_ids.as_slice(), &[MeasId(2)]); } #[test] @@ -2230,6 +5063,26 @@ mod tests { assert!(handle.try_add_gate(Gate::x(&[1])).is_ok()); } + #[test] + fn test_batched_two_qubit_gate_accepts_disjoint_pairs_and_rejects_overlap() { + let mut tick = Tick::new(); + assert!(tick.try_add_gate(Gate::cx(&[(0, 1), (2, 3)])).is_ok()); + assert_eq!(tick.len(), 1); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 1); + + let err = Tick::new() + .try_add_gate(Gate::cx(&[(0, 1), (1, 2)])) + .unwrap_err(); + match err { + TickGateError::InvalidGate { message, .. } => { + assert!(message.contains("requires distinct qubits")); + assert!(message.contains('1')); + } + TickGateError::QubitConflict(_) => panic!("overlap within one gate command is invalid"), + } + } + #[test] fn test_try_add_gate_conflict() { let mut tc = TickCircuit::new(); @@ -2240,11 +5093,12 @@ mod tests { let result = handle.try_add_gate(Gate::x(&[0])); match result { - Err(err) => { + Err(TickGateError::QubitConflict(err)) => { assert_eq!(err.conflicting_qubits, vec![QubitId::from(0)]); assert_eq!(err.tick_idx, Some(0)); } Ok(_) => panic!("Expected conflict error"), + Err(err) => panic!("Expected conflict error, got {err}"), } } @@ -2272,6 +5126,34 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_try_add_gate_rejects_invalid_gate_payload_before_storage() { + let mut tc = TickCircuit::new(); + let mut handle = tc.tick(); + let invalid = Gate::cx(&[(0, 0)]); + + let result = handle.try_add_gate(invalid); + + match result { + Err(TickGateError::InvalidGate { + message, tick_idx, .. + }) => { + assert_eq!(tick_idx, Some(0)); + assert!(message.contains("requires distinct qubits")); + } + Ok(_) => panic!("Expected invalid-gate error"), + Err(err) => panic!("Expected invalid-gate error, got {err}"), + } + assert!(tc.get_tick(0).unwrap().is_empty()); + } + + #[test] + #[should_panic(expected = "Invalid gate in tick 0")] + fn test_tick_handle_panics_on_invalid_gate_payload() { + let mut tc = TickCircuit::new(); + tc.tick().cx(&[(0, 0)]); + } + #[test] fn test_metas_on_gates() { let mut tc = TickCircuit::new(); @@ -2361,19 +5243,67 @@ mod tests { #[test] fn test_iteration_helpers() { let mut tc = TickCircuit::new(); - tc.tick().h(&[0, 1]); - tc.tick().cx(&[(0, 1)]); - tc.tick().mz(&[0, 1]); - - // Test iter_gates - let gates: Vec<_> = tc.iter_gates().collect(); - assert_eq!(gates.len(), 3); + tc.tick() + .h(&[0, 1, 2, 3]) + .meta("calibration", Attribute::String("h-cal".into())); + tc.tick().cx(&[(0, 1), (2, 3)]); + tc.tick().mz(&[0, 1, 2, 3]); - // Test iter_gates_with_tick - let gates_with_tick: Vec<_> = tc.iter_gates_with_tick().collect(); - assert_eq!(gates_with_tick.len(), 3); - assert_eq!(gates_with_tick[0].0, 0); // First gate is in tick 0 - assert_eq!(gates_with_tick[1].0, 1); // Second gate is in tick 1 + // Test explicit batched views. These preserve full Gate payloads. + let batches: Vec<_> = tc.iter_gate_batches().collect(); + assert_eq!(batches.len(), 3); + assert_eq!(batches[0].batch_index(), 0); + assert_eq!(batches[0].gate_type, GateType::H); + assert_eq!(batches[0].num_gates(), 4); + assert_eq!(batches[0].gate_count(), 4); + assert_eq!( + batches[0].get_attr("calibration"), + Some(&Attribute::String("h-cal".into())) + ); + assert_eq!(tc.get_tick(0).unwrap().gate_batches()[0].num_gates(), 4); + + let batches_with_tick: Vec<_> = tc.iter_gate_batches_with_tick().collect(); + assert_eq!(batches_with_tick.len(), 3); + assert_eq!(batches_with_tick[2].0, 2); // Third batch is in tick 2 + assert_eq!(batches_with_tick[2].1.gate_type, GateType::MZ); + + let instances_with_tick: Vec<_> = tc.iter_gate_instances_with_tick().collect(); + assert_eq!(instances_with_tick.len(), 10); + assert_eq!(instances_with_tick[0].0, 0); + assert_eq!(instances_with_tick[0].1.batch_index(), 0); + assert_eq!(instances_with_tick[0].1.instance_index(), 0); + assert_eq!(instances_with_tick[0].1.gate_type(), GateType::H); + assert_eq!(instances_with_tick[0].1.qubits(), &[QubitId::from(0)]); + assert_eq!( + instances_with_tick[0].1.to_gate().qubits.as_slice(), + &[QubitId::from(0)] + ); + assert_eq!( + instances_with_tick[0].1.get_attr("calibration"), + Some(&Attribute::String("h-cal".into())) + ); + assert_eq!(instances_with_tick[3].1.qubits(), &[QubitId::from(3)]); + assert_eq!(instances_with_tick[4].0, 1); + assert_eq!(instances_with_tick[4].1.instance_index(), 0); + assert_eq!(instances_with_tick[4].1.gate_type(), GateType::CX); + assert_eq!( + instances_with_tick[4].1.qubits(), + &[QubitId::from(0), QubitId::from(1)] + ); + assert_eq!( + instances_with_tick[5].1.qubits(), + &[QubitId::from(2), QubitId::from(3)] + ); + assert_eq!(instances_with_tick[5].1.instance_index(), 1); + assert_eq!(instances_with_tick[6].0, 2); + assert_eq!(instances_with_tick[6].1.gate_type(), GateType::MZ); + assert_eq!(instances_with_tick[6].1.qubits(), &[QubitId::from(0)]); + assert_eq!(instances_with_tick[6].1.meas_ids(), &[MeasId(0)]); + assert_eq!( + instances_with_tick[6].1.to_gate().meas_ids.as_slice(), + &[MeasId(0)] + ); + assert_eq!(instances_with_tick[9].1.meas_ids(), &[MeasId(3)]); // Test iter_ticks let ticks: Vec<_> = tc.iter_ticks().collect(); @@ -2385,15 +5315,128 @@ mod tests { // Test all_qubits let qubits = tc.all_qubits(); - assert_eq!(qubits.len(), 2); + assert_eq!(qubits.len(), 4); assert!(qubits.contains(&QubitId::from(0))); assert!(qubits.contains(&QubitId::from(1))); + assert!(qubits.contains(&QubitId::from(2))); + assert!(qubits.contains(&QubitId::from(3))); // Test gate_counts_by_type let counts = tc.gate_counts_by_type(); - assert_eq!(counts.get(&GateType::H), Some(&1)); - assert_eq!(counts.get(&GateType::CX), Some(&1)); - assert_eq!(counts.get(&GateType::MZ), Some(&1)); + assert_eq!(counts.get(&GateType::H), Some(&4)); + assert_eq!(counts.get(&GateType::CX), Some(&2)); + assert_eq!(counts.get(&GateType::MZ), Some(&4)); + } + + #[test] + fn test_gate_instance_to_gate_preserves_payloads_without_attrs() { + let angle = Angle64::from_turn_ratio(1, 8); + let mut rzz_tick = Tick::new(); + rzz_tick.add_gate(Gate::rzz(angle, &[(0, 1), (2, 3)])); + rzz_tick.set_gate_attr(0, "calibration", Attribute::String("rzz-cal".into())); + + let rzz_instances: Vec<_> = rzz_tick.iter_gate_instances().collect(); + assert_eq!(rzz_instances.len(), 2); + assert_eq!( + rzz_instances[0].get_attr("calibration"), + Some(&Attribute::String("rzz-cal".into())) + ); + assert_eq!( + rzz_instances[0].attrs().count(), + 1, + "batch metadata remains available through the instance view" + ); + assert_eq!(rzz_instances[0].angles(), &[angle]); + assert_eq!( + rzz_instances[0].to_gate(), + Gate::rzz(angle, &[(0usize, 1usize)]), + "materialized gates carry sliced support and payload, not attrs" + ); + assert_eq!( + rzz_instances[1].to_gate(), + Gate::rzz(angle, &[(2usize, 3usize)]) + ); + + let duration = 8.0_f64; + let mut idle_tick = Tick::new(); + idle_tick.add_gate(Gate::idle( + duration, + vec![QubitId::from(4), QubitId::from(5)], + )); + let idle_instances: Vec<_> = idle_tick.iter_gate_instances().collect(); + assert_eq!(idle_instances.len(), 2); + let idle_gate = idle_instances[1].to_gate(); + assert_eq!(idle_gate.gate_type, GateType::Idle); + assert_eq!(idle_gate.qubits.as_slice(), &[QubitId::from(5)]); + assert_eq!(idle_gate.params.len(), 1); + assert_eq!(idle_gate.params[0].to_bits(), duration.to_bits()); + + let mut meas_tc = TickCircuit::new(); + meas_tc.tick().mz(&[8, 9]); + let meas_instances: Vec<_> = meas_tc.get_tick(0).unwrap().iter_gate_instances().collect(); + assert_eq!(meas_instances.len(), 2); + assert_eq!( + meas_instances[0].to_gate().meas_ids.as_slice(), + &[MeasId(0)] + ); + assert_eq!( + meas_instances[1].to_gate().meas_ids.as_slice(), + &[MeasId(1)] + ); + + let channel = pecos_core::channel::Depolarizing(0.125, 6); + let mut channel_tick = Tick::new(); + channel_tick.add_gate(Gate::channel(channel.clone())); + let channel_instances: Vec<_> = channel_tick.iter_gate_instances().collect(); + assert_eq!(channel_instances.len(), 1); + let channel_gate = channel_instances[0].to_gate(); + assert_eq!(channel_gate.gate_type, GateType::Channel); + assert_eq!(channel_gate.qubits.as_slice(), &[QubitId::from(6)]); + assert_eq!(channel_gate.channel.as_ref(), Some(&channel)); + } + + #[test] + fn test_gate_instance_iteration_skips_annotation_batches() { + let mut tick = Tick::new(); + tick.add_gate(Gate::simple( + GateType::TrackedPauliMeta, + vec![QubitId::from(0), QubitId::from(1)], + )); + + let batches: Vec<_> = tick.iter_gate_batches().collect(); + assert_eq!(batches.len(), 1); + assert_eq!(batches[0].gate_count(), 0); + assert_eq!(tick.iter_gate_instances().count(), 0); + } + + #[test] + fn test_tick_to_dag_keeps_zero_gate_metadata_nodes_from_gate_count() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + tc.tick(); + tc.get_tick_mut(1).unwrap().add_gate(Gate::simple( + GateType::TrackedPauliMeta, + vec![QubitId::from(1), QubitId::from(2)], + )); + tc.tick().mz(&[0]); + + let dag = DagCircuit::from(&tc); + + assert_eq!( + dag.gate_count(), + 2, + "metadata nodes are not gate applications" + ); + let metadata_nodes: Vec<_> = dag + .nodes() + .into_iter() + .filter(|&node| { + dag.gate(node) + .is_some_and(|gate| gate.gate_type == GateType::TrackedPauliMeta) + }) + .collect(); + assert_eq!(metadata_nodes.len(), 1); + assert_eq!(dag.gate(metadata_nodes[0]).unwrap().num_gates(), 0); } #[test] @@ -2435,6 +5478,82 @@ mod tests { assert_eq!(tc.num_ticks(), 1); } + #[test] + fn test_reset_clears_annotations_and_measurement_counter() { + let mut tc = TickCircuit::new(); + let first_measurement = tc.tick().mz(&[0]); + tc.detector(&first_measurement); + tc.observable(&first_measurement); + tc.tracked_pauli(pecos_core::pauli::Z(0)); + + assert_eq!(tc.num_measurements(), 1); + assert_eq!(tc.annotations().len(), 3); + + tc.reset(); + + assert_eq!(tc.num_ticks(), 0); + assert_eq!(tc.num_measurements(), 0); + assert!(tc.annotations().is_empty()); + + let reused_measurement = tc.tick().mz(&[1]); + assert_eq!(reused_measurement[0].record_idx, 0); + } + + #[test] + fn test_metadata_helpers_build_detector_and_observable_json() { + let mut tc = TickCircuit::new(); + + let detector_id = tc + .add_detector_metadata(&[-1], Some(&[0.0, 1.0, 2.0]), Some("d0"), None) + .unwrap(); + let observable_id = tc + .add_observable_metadata(&[-1, -2], None, Some("L2")) + .unwrap(); + + assert_eq!(detector_id, 0); + assert_eq!(observable_id, 2); + assert_eq!(tc.get_meta("num_detectors"), Some(&Attribute::Int(1))); + assert_eq!(tc.get_meta("num_observables"), Some(&Attribute::Int(3))); + + let detectors = match tc.get_meta("detectors").unwrap() { + Attribute::String(value) => value, + other => panic!("expected detectors JSON string, got {other:?}"), + }; + assert_eq!( + detectors, + r#"[{"coords":[0.0,1.0,2.0],"id":0,"label":"d0","records":[-1]}]"# + ); + + let observables = match tc.get_meta("observables").unwrap() { + Attribute::String(value) => value, + other => panic!("expected observables JSON string, got {other:?}"), + }; + assert_eq!(observables, r#"[{"id":2,"label":"L2","records":[-1,-2]}]"#); + } + + #[test] + fn test_metadata_helpers_reject_conflicts_and_duplicates() { + let mut tc = TickCircuit::new(); + + let err = tc + .add_observable_metadata(&[-1], Some(1), Some("L2")) + .unwrap_err(); + assert!(err.contains("conflicts")); + + tc.add_detector_metadata(&[-1], None, None, Some(7)) + .unwrap(); + let err = tc + .add_detector_metadata(&[-2], None, None, Some(7)) + .unwrap_err(); + assert!(err.contains("already contains detector_id 7")); + + tc.add_observable_metadata(&[-1], Some(3), None).unwrap(); + let err = tc + .add_observable_metadata(&[-2], Some(3), None) + .unwrap_err(); + assert!(err.contains("already contains observable_id 3")); + } + #[test] fn test_reserve_ticks() { let mut tc = TickCircuit::new(); @@ -2467,13 +5586,13 @@ mod tests { // Check order: H at 0, X at 1, CX at 2 let tick0 = tc.get_tick(0).unwrap(); - assert_eq!(tick0.gates()[0].gate_type, GateType::H); + assert_eq!(tick0.gate_batches()[0].gate_type, GateType::H); let tick1 = tc.get_tick(1).unwrap(); - assert_eq!(tick1.gates()[0].gate_type, GateType::X); + assert_eq!(tick1.gate_batches()[0].gate_type, GateType::X); let tick2 = tc.get_tick(2).unwrap(); - assert_eq!(tick2.gates()[0].gate_type, GateType::CX); + assert_eq!(tick2.gate_batches()[0].gate_type, GateType::CX); } #[test] @@ -2488,7 +5607,7 @@ mod tests { // Z should now be at tick 0 let tick0 = tc.get_tick(0).unwrap(); - assert_eq!(tick0.gates()[0].gate_type, GateType::Z); + assert_eq!(tick0.gate_batches()[0].gate_type, GateType::Z); } #[test] @@ -2502,7 +5621,7 @@ mod tests { assert_eq!(tc.num_ticks(), 2); let tick1 = tc.get_tick(1).unwrap(); - assert_eq!(tick1.gates()[0].gate_type, GateType::X); + assert_eq!(tick1.gate_batches()[0].gate_type, GateType::X); } #[test] @@ -2518,9 +5637,18 @@ mod tests { assert_eq!(tc.num_ticks(), 3); // Check each tick has the right gate - assert_eq!(tc.get_tick(0).unwrap().gates()[0].gate_type, GateType::H); - assert_eq!(tc.get_tick(1).unwrap().gates()[0].gate_type, GateType::X); - assert_eq!(tc.get_tick(2).unwrap().gates()[0].gate_type, GateType::CX); + assert_eq!( + tc.get_tick(0).unwrap().gate_batches()[0].gate_type, + GateType::H + ); + assert_eq!( + tc.get_tick(1).unwrap().gate_batches()[0].gate_type, + GateType::X + ); + assert_eq!( + tc.get_tick(2).unwrap().gate_batches()[0].gate_type, + GateType::CX + ); } #[test] @@ -2564,7 +5692,7 @@ mod tests { assert_eq!(removed, 2); // H on q0 and CX on q2,q3 assert_eq!(tick.len(), 1); // Only X on q1 remains - assert_eq!(tick.gates()[0].gate_type, GateType::X); + assert_eq!(tick.gate_batches()[0].gate_type, GateType::X); } #[test] @@ -2605,6 +5733,29 @@ mod tests { assert!(tick.get_gate_attr(1, "x_attr").is_none()); } + #[test] + fn test_tick_remove_gate_preserves_aligned_attrs() { + let mut tc = TickCircuit::new(); + tc.tick() + .h(&[0]) + .meta("h_attr", Attribute::Int(1)) + .x(&[1]) + .meta("x_attr", Attribute::Int(2)) + .z(&[2]) + .meta("z_attr", Attribute::Int(3)); + + let tick = tc.get_tick_mut(0).unwrap(); + let removed = tick.remove_gate(0).expect("H batch should exist"); + + assert_eq!(removed.gate_type, GateType::H); + assert_eq!(tick.len(), 2); + assert_eq!(tick.gate_batches()[0].gate_type, GateType::X); + assert_eq!(tick.gate_batches()[1].gate_type, GateType::Z); + assert_eq!(tick.get_gate_attr(0, "x_attr"), Some(&Attribute::Int(2))); + assert_eq!(tick.get_gate_attr(1, "z_attr"), Some(&Attribute::Int(3))); + assert!(tick.get_gate_attr(0, "h_attr").is_none()); + } + #[test] fn test_tick_remove_gate() { let mut tc = TickCircuit::new(); @@ -2619,8 +5770,8 @@ mod tests { assert_eq!(tick.len(), 2); // Check remaining gates - assert_eq!(tick.gates()[0].gate_type, GateType::H); - assert_eq!(tick.gates()[1].gate_type, GateType::Z); + assert_eq!(tick.gate_batches()[0].gate_type, GateType::H); + assert_eq!(tick.gate_batches()[1].gate_type, GateType::Z); } #[test] @@ -2634,6 +5785,60 @@ mod tests { assert_eq!(tick.len(), 1); } + #[test] + fn test_compact_ticks_preserves_and_merges_aligned_batch_attrs() { + let mut tc = TickCircuit::new(); + tc.tick() + .h(&[0]) + .meta("calibration", Attribute::String("shared".into())); + tc.tick() + .h(&[1]) + .meta("calibration", Attribute::String("shared".into())); + + tc.compact_ticks(); + + assert_eq!(tc.num_ticks(), 1); + let tick = tc.get_tick(0).unwrap(); + assert_eq!(tick.len(), 1); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 1); + assert_eq!( + tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + assert_eq!( + tick.get_gate_attr(0, "calibration"), + Some(&Attribute::String("shared".into())) + ); + } + + #[test] + fn test_compact_ticks_keeps_different_batch_attrs_separate() { + let mut tc = TickCircuit::new(); + tc.tick() + .h(&[0]) + .meta("calibration", Attribute::String("a".into())); + tc.tick() + .h(&[1]) + .meta("calibration", Attribute::String("b".into())); + + tc.compact_ticks(); + + assert_eq!(tc.num_ticks(), 1); + let tick = tc.get_tick(0).unwrap(); + assert_eq!(tick.len(), 2); + assert_eq!(tick.gate_count(), 2); + assert_eq!(tick.gate_batch_count(), 2); + assert_eq!( + tick.get_gate_attr(0, "calibration"), + Some(&Attribute::String("a".into())) + ); + assert_eq!( + tick.get_gate_attr(1, "calibration"), + Some(&Attribute::String("b".into())) + ); + } + #[test] fn test_circuit_discard() { let mut tc = TickCircuit::new(); @@ -2734,7 +5939,7 @@ mod tests { .expect("should succeed"); let tick = tc.get_tick(0).unwrap(); - let gate = &tick.gates()[0]; + let gate = &tick.gate_batches()[0]; assert_eq!(gate.gate_type, GateType::Custom); assert_eq!(gate.angles.len(), 2); assert_eq!(gate.angles[0], a1); @@ -2753,7 +5958,7 @@ mod tests { #[test] fn test_import_signatures() { let mut tc = TickCircuit::new(); - let mut sigs = HashMap::new(); + let mut sigs = BTreeMap::new(); sigs.insert( "AOT_GATE".to_string(), GateSignature { @@ -2786,4 +5991,495 @@ mod tests { tc.reset(); assert!(tc.gate_signatures().is_empty()); } + + #[test] + fn test_tick_circuit_annotations() { + use pecos_core::pauli::X; + + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1, 2]); + tc.tick().cx(&[(0, 2)]); + tc.tick().cx(&[(1, 2)]); + let ms = tc.tick().mz(&[2]); + + assert_eq!(ms.len(), 1); + assert_eq!(ms[0].qubit, QubitId::from(2)); + + tc.detector_labeled("Z_check", &ms); + tc.observable_labeled("logical_Z", &ms); + tc.tracked_pauli_labeled("logical_X", X(0) & X(1)); + + assert_eq!(tc.annotations().len(), 3); + assert_eq!(tc.annotations()[0].label.as_deref(), Some("Z_check")); + assert_eq!(tc.annotations()[1].label.as_deref(), Some("logical_Z")); + assert_eq!(tc.annotations()[2].label.as_deref(), Some("logical_X")); + } + + #[test] + fn test_tick_to_dag_annotation_transfer() { + use pecos_core::pauli::Z; + + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1, 2]); + tc.tick().cx(&[(0, 2)]); + tc.tick().cx(&[(1, 2)]); + let ms = tc.tick().mz(&[2]); + tc.detector_labeled("det0", &ms); + tc.observable_labeled("obs0", &ms); + tc.tracked_pauli_labeled("op0", Z(0) & Z(1)); + + let dag = DagCircuit::from(&tc); + + // Annotations should transfer + assert_eq!(dag.annotations().len(), 3); + assert_eq!(dag.annotations()[0].label.as_deref(), Some("det0")); + assert_eq!(dag.annotations()[1].label.as_deref(), Some("obs0")); + assert_eq!(dag.annotations()[2].label.as_deref(), Some("op0")); + + // Kinds preserved + assert!(matches!( + dag.annotations()[0].kind, + crate::dag_circuit::AnnotationKind::Detector { .. } + )); + assert!(matches!( + dag.annotations()[1].kind, + crate::dag_circuit::AnnotationKind::Observable { .. } + )); + assert!(matches!( + dag.annotations()[2].kind, + crate::dag_circuit::AnnotationKind::TrackedPauli + )); + } + + #[test] + fn test_dag_to_tick_annotation_transfer() { + use pecos_core::pauli::X; + + let mut dag = DagCircuit::new(); + dag.pz(&[0, 1]); + dag.cx(&[(0, 1)]); + let ms = dag.mz(&[0, 1]); + dag.detector_labeled("d0", &[ms[0]]); + dag.observable_labeled("o0", &[ms[0], ms[1]]); + dag.tracked_pauli_labeled("p0", X(0) & X(1)); + + let tc = TickCircuit::from(&dag); + + assert_eq!(tc.annotations().len(), 3); + assert_eq!(tc.annotations()[0].label.as_deref(), Some("d0")); + assert_eq!(tc.annotations()[1].label.as_deref(), Some("o0")); + assert_eq!(tc.annotations()[2].label.as_deref(), Some("p0")); + } + + #[test] + fn test_annotation_round_trip() { + use pecos_core::pauli::X; + + // Build TickCircuit with annotations + let mut tc1 = TickCircuit::new(); + tc1.tick().pz(&[0, 1, 2]); + tc1.tick().cx(&[(0, 2)]); + tc1.tick().cx(&[(1, 2)]); + let ms = tc1.tick().mz(&[2]); + tc1.detector_labeled("syndr", &ms); + let ms_data = tc1.tick().mz(&[0, 1]); + tc1.observable_labeled("log_Z", &ms_data); + tc1.tracked_pauli_labeled("log_X", X(0) & X(1)); + + // TickCircuit -> DagCircuit -> TickCircuit + let dag = DagCircuit::from(&tc1); + let tc2 = TickCircuit::from(&dag); + + // Annotation count and labels preserved + assert_eq!(tc2.annotations().len(), tc1.annotations().len()); + for (a1, a2) in tc1.annotations().iter().zip(tc2.annotations()) { + assert_eq!(a1.label, a2.label); + assert_eq!(a1.pauli, a2.pauli); + } + } + + #[test] + fn test_mz_returns_refs() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + let ms = tc.tick().mz(&[0, 1]); + + assert_eq!(ms.len(), 2); + assert_eq!(ms[0].qubit, QubitId::from(0)); + assert_eq!(ms[1].qubit, QubitId::from(1)); + // Both from same tick and gate + assert_eq!(ms[0].tick, ms[1].tick); + assert_eq!(ms[0].gate_idx, ms[1].gate_idx); + } + + #[test] + fn test_fill_idle_gates() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); // qubit 1 idle + tc.tick().cx(&[(0, 1)]); // none idle + + let count_before = tc.gate_count(); + tc.fill_idle_gates(); + let count_after = tc.gate_count(); + + // Tick 0: qubit 1 was idle, should get an idle gate + assert!(count_after > count_before, "Should have added idle gates"); + } + + #[test] + fn test_channel_gate_is_first_class_tick_operation() { + let mut tc = TickCircuit::new(); + tc.tick() + .channel(pecos_core::channel::Depolarizing(0.25, 0)); + + let gate = &tc.get_tick(0).unwrap().gate_batches()[0]; + assert_eq!(gate.gate_type, GateType::Channel); + assert_eq!(gate.qubits.as_slice(), &[QubitId::from(0)]); + assert!(gate.channel_expr().is_some()); + assert!(tc.has_channel_operations()); + assert!(gate.validate().is_ok()); + } + + #[test] + fn test_with_noise_inserts_channel_ticks() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]).x(&[1]); + tc.tick().cx(&[(0, 1)]); + + let noisy = tc.with_noise(&|gate: &Gate| { + gate.qubits + .iter() + .map(|q| pecos_core::channel::Depolarizing(0.01, q.index())) + .collect() + }); + + assert_eq!(noisy.num_ticks(), 4); + assert_eq!( + noisy.get_tick(0).unwrap().gate_batches()[0].gate_type, + GateType::H + ); + assert!( + noisy + .get_tick(1) + .unwrap() + .gate_batches() + .iter() + .all(Gate::is_channel) + ); + assert_eq!( + noisy.get_tick(2).unwrap().gate_batches()[0].gate_type, + GateType::CX + ); + assert!( + noisy + .get_tick(3) + .unwrap() + .gate_batches() + .iter() + .all(Gate::is_channel) + ); + } + + #[test] + fn test_with_noise_on_batched_source_gate_emits_per_qubit_channels() { + use std::cell::{Cell, RefCell}; + + let mut tc = TickCircuit::new(); + tc.tick().h(&[0, 1]); + + let calls = Cell::new(0); + let seen_qubits = RefCell::new(Vec::new()); + let noisy = tc.with_noise(&|gate: &Gate| { + calls.set(calls.get() + 1); + seen_qubits.borrow_mut().push(gate.qubits.clone()); + gate.qubits + .iter() + .map(|qubit| pecos_core::channel::Depolarizing(0.01, qubit.index())) + .collect() + }); + + assert_eq!(calls.get(), 1); + assert_eq!( + seen_qubits.borrow()[0].as_slice(), + &[QubitId::from(0), QubitId::from(1)] + ); + assert_eq!(noisy.num_ticks(), 2); + assert_eq!(noisy.get_tick(0).unwrap().gate_count(), 2); + + let noise_tick = noisy.get_tick(1).unwrap(); + assert_eq!(noise_tick.len(), 2); + assert_eq!(noise_tick.gate_count(), 2); + assert!(noise_tick.gate_batches().iter().all(Gate::is_channel)); + assert_eq!( + noise_tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0)] + ); + assert_eq!( + noise_tick.gate_batches()[1].qubits.as_slice(), + &[QubitId::from(1)] + ); + } + + #[test] + fn test_with_noise_on_batched_measurement_places_channels_after_measurement_tick() { + use std::cell::{Cell, RefCell}; + + let mut tc = TickCircuit::new(); + tc.tick().mz(&[0, 1]); + + let calls = Cell::new(0); + let seen_measurement_ids = RefCell::new(Vec::new()); + let noisy = tc.with_noise(&|gate: &Gate| { + assert_eq!(gate.gate_type, GateType::MZ); + calls.set(calls.get() + 1); + seen_measurement_ids + .borrow_mut() + .extend(gate.meas_ids.iter().copied()); + gate.qubits + .iter() + .map(|qubit| pecos_core::channel::Dephasing(0.02, qubit.index())) + .collect() + }); + + assert_eq!(calls.get(), 1); + assert_eq!( + seen_measurement_ids.borrow().as_slice(), + &[MeasId(0), MeasId(1)] + ); + assert_eq!(noisy.num_ticks(), 2); + + let meas_tick = noisy.get_tick(0).unwrap(); + assert_eq!(meas_tick.gate_batches()[0].gate_type, GateType::MZ); + assert_eq!( + meas_tick.gate_batches()[0].meas_ids.as_slice(), + &[MeasId(0), MeasId(1)] + ); + + let noise_tick = noisy.get_tick(1).unwrap(); + assert_eq!(noise_tick.len(), 2); + assert!(noise_tick.gate_batches().iter().all(Gate::is_channel)); + assert_eq!( + noise_tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0)] + ); + assert_eq!( + noise_tick.gate_batches()[1].qubits.as_slice(), + &[QubitId::from(1)] + ); + } + + #[test] + fn test_with_noise_empty_channels_preserves_tick_structure() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]).x(&[1]); + tc.tick().cx(&[(0, 1)]); + tc.tick().mz(&[0, 1]); + + let noisy = tc.with_noise(&|_: &Gate| Vec::new()); + + assert_eq!(noisy.num_ticks(), tc.num_ticks()); + for tick_idx in 0..tc.num_ticks() { + let original = tc.get_tick(tick_idx).unwrap(); + let copied = noisy.get_tick(tick_idx).unwrap(); + assert_eq!(copied.gate_batches().len(), original.gate_batches().len()); + for (copied_gate, original_gate) in + copied.gate_batches().iter().zip(original.gate_batches()) + { + assert_eq!(copied_gate.gate_type, original_gate.gate_type); + assert_eq!(copied_gate.qubits, original_gate.qubits); + assert!(!copied_gate.is_channel()); + } + } + } + + #[test] + fn test_with_noise_splits_conflicting_channel_ticks() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]); + + let noisy = tc.with_noise(&|_: &Gate| { + vec![ + pecos_core::channel::Depolarizing(0.01, 0), + pecos_core::channel::Dephasing(0.02, 0), + ] + }); + + assert_eq!(noisy.num_ticks(), 3); + assert_eq!( + noisy.get_tick(0).unwrap().gate_batches()[0].gate_type, + GateType::H + ); + + let first_noise_tick = noisy.get_tick(1).unwrap(); + assert_eq!(first_noise_tick.gate_batches().len(), 1); + assert!(first_noise_tick.gate_batches()[0].is_channel()); + assert_eq!( + first_noise_tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0)] + ); + + let second_noise_tick = noisy.get_tick(2).unwrap(); + assert_eq!(second_noise_tick.gate_batches().len(), 1); + assert!(second_noise_tick.gate_batches()[0].is_channel()); + assert_eq!( + second_noise_tick.gate_batches()[0].qubits.as_slice(), + &[QubitId::from(0)] + ); + } + + #[test] + fn test_with_noise_places_measurement_channels_after_measurement_tick() { + let mut tc = TickCircuit::new(); + tc.tick().mz(&[0]); + + let noisy = tc.with_noise(&|gate: &Gate| { + if gate.gate_type == GateType::MZ { + vec![pecos_core::channel::Dephasing(0.25, 0)] + } else { + Vec::new() + } + }); + + assert_eq!(noisy.num_ticks(), 2); + assert_eq!( + noisy.get_tick(0).unwrap().gate_batches()[0].gate_type, + GateType::MZ + ); + let channel = &noisy.get_tick(1).unwrap().gate_batches()[0]; + assert!(channel.is_channel()); + assert_eq!(channel.qubits.as_slice(), &[QubitId::from(0)]); + } + + #[test] + fn test_with_noise_packs_disjoint_channels_and_splits_conflicts() { + let mut tc = TickCircuit::new(); + tc.tick().h(&[0]).x(&[1]); + + let noisy = tc.with_noise(&|gate: &Gate| { + gate.qubits + .iter() + .flat_map(|q| { + [ + pecos_core::channel::Depolarizing(0.01, q.index()), + pecos_core::channel::Dephasing(0.02, q.index()), + ] + }) + .collect() + }); + + assert_eq!(noisy.num_ticks(), 3); + assert_eq!(noisy.get_tick(1).unwrap().gate_batches().len(), 2); + assert_eq!(noisy.get_tick(2).unwrap().gate_batches().len(), 2); + assert!( + noisy + .get_tick(1) + .unwrap() + .gate_batches() + .iter() + .all(Gate::is_channel) + ); + assert!( + noisy + .get_tick(2) + .unwrap() + .gate_batches() + .iter() + .all(Gate::is_channel) + ); + } + + #[test] + fn test_with_noise_rejects_existing_channel_operations() { + let mut tc = TickCircuit::new(); + tc.tick() + .channel(pecos_core::channel::Depolarizing(0.25, 0)); + + let err = tc + .try_with_noise(&|_: &Gate| vec![pecos_core::channel::Depolarizing(0.01, 0)]) + .unwrap_err(); + + assert!(err.contains("already contains channel operations")); + assert!(err.contains("tick 0")); + assert!(err.contains("gate 0")); + } + + #[test] + fn test_meas_record_idx_single_qubit() { + let mut tc = TickCircuit::new(); + let m0 = tc.tick().mz(&[0]); + let m1 = tc.tick().mz(&[1]); + assert_eq!(m0[0].record_idx, 0); + assert_eq!(m1[0].record_idx, 1); + } + + #[test] + fn test_meas_record_idx_multi_qubit() { + let mut tc = TickCircuit::new(); + let ms = tc.tick().mz(&[0, 1, 2]); + assert_eq!(ms[0].record_idx, 0); + assert_eq!(ms[1].record_idx, 1); + assert_eq!(ms[2].record_idx, 2); + // Next measurement continues the count + let m2 = tc.tick().mz(&[3]); + assert_eq!(m2[0].record_idx, 3); + } + + #[test] + fn test_detector_uses_record_idx() { + // Two qubits measured in one gate: detector referencing each + // should get DIFFERENT record indices (not the same gate index). + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + let ms = tc.tick().mz(&[0, 1]); + + // Detector on qubit 0's measurement + tc.detector(&[ms[0]]); + // Detector on qubit 1's measurement + tc.detector(&[ms[1]]); + + let anns = tc.annotations(); + match &anns[0].kind { + AnnotationKind::Detector { + measurement_nodes, .. + } => { + assert_eq!(measurement_nodes, &[0], "D0 should reference record 0 (q0)"); + } + _ => panic!("Expected detector"), + } + match &anns[1].kind { + AnnotationKind::Detector { + measurement_nodes, .. + } => { + assert_eq!(measurement_nodes, &[1], "D1 should reference record 1 (q1)"); + } + _ => panic!("Expected detector"), + } + } + + #[test] + fn test_detector_multi_qubit_mz_no_xor_cancel() { + // Bug regression: two refs from same multi-qubit MZ gate + // used to have the same gate_idx, causing XOR cancellation. + // With record_idx they should be distinct. + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + let ms = tc.tick().mz(&[0, 1]); + + // Detector comparing both measurements (XOR of records 0 and 1) + tc.detector(&[ms[0], ms[1]]); + + let anns = tc.annotations(); + match &anns[0].kind { + AnnotationKind::Detector { + measurement_nodes, .. + } => { + assert_eq!(measurement_nodes.len(), 2); + assert_ne!( + measurement_nodes[0], measurement_nodes[1], + "Two qubits from same MZ must have different record indices" + ); + } + _ => panic!("Expected detector"), + } + } } diff --git a/crates/pecos-quantum/src/tick_circuit_soa.rs b/crates/pecos-quantum/src/tick_circuit_soa.rs deleted file mode 100644 index db9b5e3f0..000000000 --- a/crates/pecos-quantum/src/tick_circuit_soa.rs +++ /dev/null @@ -1,1473 +0,0 @@ -//! Data-Oriented Design (DOD) `TickCircuit` implementation. -//! -//! This module provides `TickCircuitSoA`, an alternative representation of tick-based -//! quantum circuits optimized for **batched simulation**. -//! -//! # Design Goals -//! -//! 1. **Batched gate application**: Gates grouped by type within each tick for batch execution -//! 2. **Cache-friendly memory layout**: Qubits for same-type gates stored contiguously -//! 3. **O(1) lookups**: Pre-computed indexes for qubit-to-gate and tick-to-gate queries -//! 4. **Efficient simulation**: Direct batch calls to simulator without per-gate dispatch -//! -//! # Architecture -//! -//! ```text -//! ┌─────────────────────────────────────────────────────────────────┐ -//! │ TickCircuitSoA │ -//! ├─────────────────────────────────────────────────────────────────┤ -//! │ TickGateGroups (simulation-optimized) │ -//! │ ├── ticks[0]: [H→[q0,q1], CX→[c0,t0,c1,t1], ...] │ -//! │ ├── ticks[1]: [Mz→[q0,q1,q2], ...] │ -//! │ └── ... │ -//! ├─────────────────────────────────────────────────────────────────┤ -//! │ GateStorage (SoA layout for individual gate access) │ -//! │ ├── types: [H, CX, H, Mz, ...] (Vec) │ -//! │ ├── tick_ids: [0, 0, 1, 2, ...] (Vec) │ -//! │ ├── qubit_spans: [(0,1), (1,3), (3,4), ...] (Vec<(u32,u32)>) │ -//! │ └── qubits: [0, 0, 1, 0, ...] (Vec) │ -//! ├─────────────────────────────────────────────────────────────────┤ -//! │ CircuitIndexes │ -//! │ ├── tick_gates: [[0,1], [2,3], ...] (Vec>) │ -//! │ ├── qubit_to_gates: [[0,1], [1], ...] (Vec) │ -//! │ └── max_qubit: usize │ -//! └─────────────────────────────────────────────────────────────────┘ -//! ``` -//! -//! # Batched Simulation -//! -//! The key optimization is grouping gates by type within each tick: -//! -//! ```text -//! // Without batching (gate-by-gate): -//! for gate in tick.gates(): -//! match gate.type: -//! H => sim.h(&[gate.qubit]) // 4 separate calls -//! H => sim.h(&[gate.qubit]) -//! CX => sim.cx(&[c, t]) -//! CX => sim.cx(&[c, t]) -//! -//! // With batching (one call per type): -//! sim.h(&[q0, q1, q2, q3]) // 1 batched call -//! sim.cx(&[c0, t0, c1, t1]) // 1 batched call -//! ``` -//! -//! # Usage -//! -//! ``` -//! use pecos_quantum::TickCircuitSoA; -//! -//! // Build using the builder pattern -//! let mut builder = TickCircuitSoA::builder(); -//! builder -//! .tick() -//! .h(&[0, 1]) -//! .cx(&[(0, 1)]) -//! .tick() -//! .mz(&[0, 1]); -//! let circuit = builder.build(); -//! ``` -//! -//! With a simulator, the batched iteration looks like: -//! -//! ```text -//! for (tick_idx, tick) in circuit.iter_ticks_batched() { -//! for batch in tick.iter() { -//! match batch.gate_type { -//! GateType::H => sim.h(batch.qubits()), -//! GateType::CX => sim.cx(batch.qubits()), -//! GateType::MZ => { sim.mz(batch.qubits()); } -//! // ... -//! } -//! } -//! } -//! ``` - -use crate::Attribute; -use pecos_core::gate_type::GateType; -use pecos_core::{Angle64, QubitId}; -use smallvec::SmallVec; -use std::collections::BTreeMap; - -// ============================================================================ -// Gate Batching for Simulation -// ============================================================================ - -/// A batch of gates of the same type, ready for efficient batch application. -/// -/// For single-qubit gates, `qubits` contains one qubit per gate instance. -/// For two-qubit gates, `qubits` contains pairs: `[c0, t0, c1, t1, ...]`. -#[derive(Debug, Clone)] -pub struct GateBatch { - /// The gate type for all gates in this batch. - pub gate_type: GateType, - /// Qubits for batch application (contiguous for cache efficiency). - /// Single-qubit: `[q0, q1, q2, ...]` - /// Two-qubit: `[c0, t0, c1, t1, ...]` (control-target pairs) - pub qubits: SmallVec<[QubitId; 16]>, - /// Angles for parameterized gates (one per gate instance). - pub angles: SmallVec<[Angle64; 4]>, - /// Parameters for gates with params (e.g., idle duration). - pub params: SmallVec<[f64; 4]>, -} - -impl GateBatch { - /// Creates a new empty batch for the given gate type. - #[inline] - #[must_use] - pub fn new(gate_type: GateType) -> Self { - Self { - gate_type, - qubits: SmallVec::new(), - angles: SmallVec::new(), - params: SmallVec::new(), - } - } - - /// Returns the qubits for batch application. - #[inline] - #[must_use] - pub fn qubits(&self) -> &[QubitId] { - &self.qubits - } - - /// Returns the angles for parameterized gates. - #[inline] - #[must_use] - pub fn angles(&self) -> &[Angle64] { - &self.angles - } - - /// Returns the number of gate instances in this batch. - #[inline] - #[must_use] - pub fn gate_count(&self) -> usize { - let arity = self.gate_type.quantum_arity(); - self.qubits - .len() - .checked_div(arity) - .unwrap_or(self.qubits.len()) - } - - /// Returns true if the batch is empty. - #[inline] - #[must_use] - pub fn is_empty(&self) -> bool { - self.qubits.is_empty() - } - - /// Adds a gate's qubits to this batch. - #[inline] - pub fn add_qubits(&mut self, qubits: &[QubitId]) { - self.qubits.extend_from_slice(qubits); - } - - /// Adds a gate's angle to this batch. - #[inline] - pub fn add_angle(&mut self, angle: Angle64) { - self.angles.push(angle); - } -} - -/// Gates for a single tick, grouped by type for batch application. -#[derive(Debug, Clone, Default)] -pub struct TickBatches { - /// Gate batches, one per gate type that appears in this tick. - /// Ordered by insertion (first gate type seen comes first). - pub batches: SmallVec<[GateBatch; 8]>, -} - -impl TickBatches { - /// Creates a new empty tick. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Returns an iterator over the batches. - #[inline] - pub fn iter(&self) -> impl Iterator { - self.batches.iter() - } - - /// Returns the number of batches. - #[inline] - #[must_use] - pub fn batch_count(&self) -> usize { - self.batches.len() - } - - /// Returns the total number of gates in this tick. - #[must_use] - pub fn gate_count(&self) -> usize { - self.batches.iter().map(GateBatch::gate_count).sum() - } - - /// Adds a gate to the appropriate batch (creates batch if needed). - /// - /// # Panics - /// Panics if internal batch list is unexpectedly empty after insertion. - pub fn add_gate(&mut self, gate_type: GateType, qubits: &[QubitId], angles: &[Angle64]) { - // Find or create batch for this gate type - let batch = if let Some(batch) = self.batches.iter_mut().find(|b| b.gate_type == gate_type) - { - batch - } else { - self.batches.push(GateBatch::new(gate_type)); - self.batches.last_mut().expect("batch was just pushed") - }; - - batch.add_qubits(qubits); - for &angle in angles { - batch.add_angle(angle); - } - } - - /// Returns the batch for a specific gate type, if present. - #[inline] - #[must_use] - pub fn batch_for_type(&self, gate_type: GateType) -> Option<&GateBatch> { - self.batches.iter().find(|b| b.gate_type == gate_type) - } -} - -/// Pre-grouped gates by type for each tick, optimized for batched simulation. -/// -/// This is the primary structure for efficient circuit execution: -/// - Gates are grouped by type within each tick -/// - Qubits for same-type gates are stored contiguously -/// - Enables single batch calls to the simulator per gate type -#[derive(Debug, Clone, Default)] -pub struct TickGateGroups { - /// For each tick, the batched gates. - pub ticks: Vec, - /// Number of ticks. - pub num_ticks: usize, - /// Maximum qubit index seen. - pub max_qubit: usize, -} - -impl TickGateGroups { - /// Creates empty gate groups. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Returns the number of ticks. - #[inline] - #[must_use] - pub fn num_ticks(&self) -> usize { - self.num_ticks - } - - /// Returns the batches for a specific tick. - #[inline] - #[must_use] - pub fn tick(&self, tick_idx: usize) -> Option<&TickBatches> { - self.ticks.get(tick_idx) - } - - /// Returns an iterator over all ticks. - #[inline] - pub fn iter_ticks(&self) -> impl Iterator { - self.ticks.iter().enumerate() - } - - /// Ensures capacity for the given tick index. - fn ensure_tick(&mut self, tick_idx: usize) { - if tick_idx >= self.ticks.len() { - self.ticks.resize_with(tick_idx + 1, TickBatches::new); - } - self.num_ticks = self.num_ticks.max(tick_idx + 1); - } - - /// Adds a gate to the appropriate tick and batch. - pub fn add_gate( - &mut self, - tick_idx: usize, - gate_type: GateType, - qubits: &[QubitId], - angles: &[Angle64], - ) { - self.ensure_tick(tick_idx); - self.ticks[tick_idx].add_gate(gate_type, qubits, angles); - - // Track max qubit - for qubit in qubits { - self.max_qubit = self.max_qubit.max(qubit.index()); - } - } - - /// Clears all gate groups. - pub fn clear(&mut self) { - self.ticks.clear(); - self.num_ticks = 0; - self.max_qubit = 0; - } - - /// Returns the total number of gates across all ticks. - #[must_use] - pub fn total_gate_count(&self) -> usize { - self.ticks.iter().map(TickBatches::gate_count).sum() - } -} - -// ============================================================================ -// Gate ID (Stable, Generational) -// ============================================================================ - -/// A stable identifier for a gate in a `TickCircuitSoA`. -/// -/// Unlike raw indices, `GateId` includes a generation counter that allows -/// detecting use-after-free when gates are removed. This provides safety -/// without the overhead of reference counting. -#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Hash)] -pub struct GateId { - /// Index into the gate storage arrays. - index: u32, - /// Generation counter for detecting stale IDs. - generation: u16, -} - -impl GateId { - /// Creates a new `GateId`. - #[inline] - #[must_use] - pub const fn new(index: u32, generation: u16) -> Self { - Self { index, generation } - } - - /// Returns the raw index (use with caution). - #[inline] - #[must_use] - pub const fn index(self) -> usize { - self.index as usize - } - - /// Returns the generation. - #[inline] - #[must_use] - pub const fn generation(self) -> u16 { - self.generation - } -} - -// ============================================================================ -// Gate Storage (SoA Layout) -// ============================================================================ - -/// Structure-of-Arrays storage for gate data. -/// -/// All gates are stored in parallel arrays for cache-friendly access. -/// Variable-length data (qubits, angles) uses span-based indexing into -/// contiguous backing arrays. -#[derive(Debug, Clone, Default)] -pub struct GateStorage { - // Core gate data (one element per gate) - /// Gate types (H, CX, Mz, etc.) - pub types: Vec, - /// Which tick each gate belongs to - pub tick_ids: Vec, - /// Span (start, end) into the qubits array - pub qubit_spans: Vec<(u32, u32)>, - /// Span (start, end) into the angles array - pub angle_spans: Vec<(u32, u32)>, - /// Generation counter for each slot (for `GateId` validation) - pub generations: Vec, - /// Whether each slot is occupied (for sparse storage after removals) - pub occupied: Vec, - - // Backing arrays for variable-length data - /// All qubits, indexed by `qubit_spans` - pub qubits: Vec, - /// All angles, indexed by `angle_spans` - pub angles: Vec, - /// All params (e.g., idle duration), indexed similarly - pub param_spans: Vec<(u32, u32)>, - pub params: Vec, - - // Free list for slot reuse after removal - free_slots: Vec, -} - -impl GateStorage { - /// Creates empty gate storage. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Creates gate storage with pre-allocated capacity. - #[must_use] - pub fn with_capacity(gate_capacity: usize, qubit_capacity: usize) -> Self { - Self { - types: Vec::with_capacity(gate_capacity), - tick_ids: Vec::with_capacity(gate_capacity), - qubit_spans: Vec::with_capacity(gate_capacity), - angle_spans: Vec::with_capacity(gate_capacity), - generations: Vec::with_capacity(gate_capacity), - occupied: Vec::with_capacity(gate_capacity), - qubits: Vec::with_capacity(qubit_capacity), - angles: Vec::new(), - param_spans: Vec::with_capacity(gate_capacity), - params: Vec::new(), - free_slots: Vec::new(), - } - } - - /// Returns the number of gates (including removed slots). - #[inline] - #[must_use] - pub fn len(&self) -> usize { - self.types.len() - } - - /// Returns true if there are no gates. - #[inline] - #[must_use] - pub fn is_empty(&self) -> bool { - self.types.is_empty() - } - - /// Returns the number of active (non-removed) gates. - #[must_use] - pub fn active_count(&self) -> usize { - self.occupied.iter().filter(|&&o| o).count() - } - - /// Adds a gate and returns its ID. - pub fn add_gate( - &mut self, - gate_type: GateType, - tick_id: u16, - qubits: &[QubitId], - angles: &[Angle64], - params: &[f64], - ) -> GateId { - let (index, generation) = if let Some(slot) = self.free_slots.pop() { - // Reuse a freed slot - let idx = slot as usize; - self.generations[idx] = self.generations[idx].wrapping_add(1); - self.types[idx] = gate_type; - self.tick_ids[idx] = tick_id; - self.occupied[idx] = true; - (slot, self.generations[idx]) - } else { - // Allocate new slot - #[allow(clippy::cast_possible_truncation)] // gate index fits in u32 - let idx = self.types.len() as u32; - self.types.push(gate_type); - self.tick_ids.push(tick_id); - self.generations.push(0); - self.occupied.push(true); - // Placeholders for spans - will be set below - self.qubit_spans.push((0, 0)); - self.angle_spans.push((0, 0)); - self.param_spans.push((0, 0)); - (idx, 0) - }; - - let idx = index as usize; - - // Add qubits - #[allow(clippy::cast_possible_truncation)] // qubit pool index fits in u32 - let qubit_start = self.qubits.len() as u32; - self.qubits.extend_from_slice(qubits); - #[allow(clippy::cast_possible_truncation)] // qubit pool index fits in u32 - let qubit_end = self.qubits.len() as u32; - self.qubit_spans[idx] = (qubit_start, qubit_end); - - // Add angles - #[allow(clippy::cast_possible_truncation)] // angle pool index fits in u32 - let angle_start = self.angles.len() as u32; - self.angles.extend_from_slice(angles); - #[allow(clippy::cast_possible_truncation)] // angle pool index fits in u32 - let angle_end = self.angles.len() as u32; - self.angle_spans[idx] = (angle_start, angle_end); - - // Add params - #[allow(clippy::cast_possible_truncation)] // param pool index fits in u32 - let param_start = self.params.len() as u32; - self.params.extend_from_slice(params); - #[allow(clippy::cast_possible_truncation)] // param pool index fits in u32 - let param_end = self.params.len() as u32; - self.param_spans[idx] = (param_start, param_end); - - GateId::new(index, generation) - } - - /// Validates that a `GateId` is still valid. - #[inline] - #[must_use] - pub fn is_valid(&self, id: GateId) -> bool { - let idx = id.index(); - idx < self.len() && self.generations[idx] == id.generation() && self.occupied[idx] - } - - /// Returns the gate type for a valid ID. - #[inline] - #[must_use] - pub fn gate_type(&self, id: GateId) -> Option { - if self.is_valid(id) { - Some(self.types[id.index()]) - } else { - None - } - } - - /// Returns the tick ID for a valid gate. - #[inline] - #[must_use] - pub fn tick_id(&self, id: GateId) -> Option { - if self.is_valid(id) { - Some(self.tick_ids[id.index()]) - } else { - None - } - } - - // ========================================================================= - // Unchecked accessors for hot paths (internal use) - // ========================================================================= - - /// Returns the gate type without validation. Use only when index is known valid. - #[inline] - #[must_use] - pub fn type_unchecked(&self, idx: usize) -> GateType { - self.types[idx] - } - - /// Returns the tick ID without validation. - #[inline] - #[must_use] - pub fn tick_id_unchecked(&self, idx: usize) -> u16 { - self.tick_ids[idx] - } - - /// Returns the qubits without validation. - #[inline] - #[must_use] - pub fn qubits_unchecked(&self, idx: usize) -> &[QubitId] { - let (start, end) = self.qubit_spans[idx]; - &self.qubits[start as usize..end as usize] - } - - /// Returns whether the slot is occupied. - #[inline] - #[must_use] - pub fn is_occupied(&self, idx: usize) -> bool { - idx < self.occupied.len() && self.occupied[idx] - } - - /// Returns the total number of slots (for iteration bounds). - #[inline] - #[must_use] - pub fn slot_count(&self) -> usize { - self.types.len() - } - - /// Returns the qubits for a valid gate. - #[inline] - #[must_use] - pub fn gate_qubits(&self, id: GateId) -> Option<&[QubitId]> { - if self.is_valid(id) { - let (start, end) = self.qubit_spans[id.index()]; - Some(&self.qubits[start as usize..end as usize]) - } else { - None - } - } - - /// Returns the angles for a valid gate. - #[inline] - #[must_use] - pub fn gate_angles(&self, id: GateId) -> Option<&[Angle64]> { - if self.is_valid(id) { - let (start, end) = self.angle_spans[id.index()]; - Some(&self.angles[start as usize..end as usize]) - } else { - None - } - } - - /// Returns the params for a valid gate. - #[inline] - #[must_use] - pub fn gate_params(&self, id: GateId) -> Option<&[f64]> { - if self.is_valid(id) { - let (start, end) = self.param_spans[id.index()]; - Some(&self.params[start as usize..end as usize]) - } else { - None - } - } - - /// Removes a gate by ID. The slot can be reused. - pub fn remove(&mut self, id: GateId) -> bool { - if self.is_valid(id) { - let idx = id.index(); - self.occupied[idx] = false; - self.free_slots.push(id.index); - true - } else { - false - } - } - - /// Clears all gates. - pub fn clear(&mut self) { - self.types.clear(); - self.tick_ids.clear(); - self.qubit_spans.clear(); - self.angle_spans.clear(); - self.param_spans.clear(); - self.generations.clear(); - self.occupied.clear(); - self.qubits.clear(); - self.angles.clear(); - self.params.clear(); - self.free_slots.clear(); - } - - /// Iterator over all valid gate IDs. - pub fn iter_ids(&self) -> impl Iterator + '_ { - (0..self.len()).filter_map(move |idx| { - if self.occupied[idx] { - #[allow(clippy::cast_possible_truncation)] // gate index fits in u32 - Some(GateId::new(idx as u32, self.generations[idx])) - } else { - None - } - }) - } -} - -// ============================================================================ -// Circuit Indexes -// ============================================================================ - -/// Pre-computed indexes for efficient circuit queries. -/// -/// Uses raw u32 indices instead of `GateIds` for minimal overhead in hot paths. -#[derive(Debug, Clone, Default)] -pub struct CircuitIndexes { - /// For each tick, the list of gate indices in that tick. - /// Using Vec> for simplicity; could use CSR format for less allocation. - pub tick_gates: Vec>, - - /// For each qubit, the list of gate indices that touch it. - /// Indexed by qubit index; grows dynamically. - /// Uses u32 instead of `GateId` to avoid validation overhead. - pub qubit_to_gates: Vec>, - - /// For each qubit, gates sorted by tick (for efficient backward traversal). - /// Each entry is (`tick_id`, `gate_idx`). - pub qubit_gates_by_tick: Vec>, - - /// Maximum qubit index seen. - pub max_qubit: usize, - - /// Number of ticks. - pub num_ticks: usize, -} - -impl CircuitIndexes { - /// Creates empty indexes. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Ensures the qubit index can accommodate the given qubit. - fn ensure_qubit_capacity(&mut self, qubit: usize) { - if qubit >= self.qubit_to_gates.len() { - self.qubit_to_gates.resize(qubit + 1, SmallVec::new()); - self.qubit_gates_by_tick.resize(qubit + 1, Vec::new()); - } - self.max_qubit = self.max_qubit.max(qubit); - } - - /// Registers a gate in the indexes (using raw index). - pub fn register_gate_raw(&mut self, gate_idx: u32, tick_id: u16, qubits: &[QubitId]) { - // Add to tick index - let tick = tick_id as usize; - if tick >= self.tick_gates.len() { - self.tick_gates.resize(tick + 1, Vec::new()); - } - self.tick_gates[tick].push(gate_idx); - self.num_ticks = self.num_ticks.max(tick + 1); - - // Add to qubit indexes - for qubit in qubits { - let q = qubit.index(); - self.ensure_qubit_capacity(q); - self.qubit_to_gates[q].push(gate_idx); - self.qubit_gates_by_tick[q].push((tick_id, gate_idx)); - } - } - - /// Registers a gate in the qubit index (`GateId` version for compatibility). - pub fn register_gate(&mut self, gate_id: GateId, qubits: &[QubitId]) { - for qubit in qubits { - let q = qubit.index(); - self.ensure_qubit_capacity(q); - self.qubit_to_gates[q].push(gate_id.index); - } - } - - /// Returns all gate indices touching the given qubit. - #[inline] - #[must_use] - pub fn gates_touching_qubit_raw(&self, qubit: usize) -> &[u32] { - if qubit < self.qubit_to_gates.len() { - &self.qubit_to_gates[qubit] - } else { - &[] - } - } - - /// Returns all gates touching the given qubit (`GateId` version). - #[inline] - #[must_use] - pub fn gates_touching_qubit(&self, _qubit: usize) -> &[GateId] { - // Note: This is a bit of a hack - we're reinterpreting u32 as GateId - // In practice, for read-only circuits without removal, generation is always 0 - &[] // Return empty - use gates_touching_qubit_raw instead - } - - /// Returns gate indices in a specific tick. - #[inline] - #[must_use] - pub fn gates_in_tick(&self, tick: usize) -> &[u32] { - if tick < self.tick_gates.len() { - &self.tick_gates[tick] - } else { - &[] - } - } - - /// Returns gates on a qubit sorted by tick (for backward traversal). - #[inline] - #[must_use] - pub fn qubit_gates_sorted(&self, qubit: usize) -> &[(u16, u32)] { - if qubit < self.qubit_gates_by_tick.len() { - &self.qubit_gates_by_tick[qubit] - } else { - &[] - } - } - - /// Sorts the `qubit_gates_by_tick` for each qubit (call after building). - pub fn finalize(&mut self) { - for gates in &mut self.qubit_gates_by_tick { - gates.sort_by_key(|&(tick, _)| tick); - } - } - - /// Clears all indexes. - pub fn clear(&mut self) { - self.tick_gates.clear(); - self.qubit_to_gates.clear(); - self.qubit_gates_by_tick.clear(); - self.max_qubit = 0; - self.num_ticks = 0; - } - - /// Rebuilds indexes from gate storage. - pub fn rebuild(&mut self, storage: &GateStorage) { - self.clear(); - - for idx in 0..storage.slot_count() { - if storage.is_occupied(idx) { - let tick_id = storage.tick_id_unchecked(idx); - let qubits = storage.qubits_unchecked(idx); - #[allow(clippy::cast_possible_truncation)] // gate index fits in u32 - self.register_gate_raw(idx as u32, tick_id, qubits); - } - } - - self.finalize(); - } -} - -// ============================================================================ -// Metadata Storage -// ============================================================================ - -/// Lazy metadata storage - only allocates when metadata is actually used. -#[derive(Debug, Clone, Default)] -pub struct MetadataStorage { - /// Per-gate attributes. - pub gate_attrs: BTreeMap>, - /// Per-tick attributes. - pub tick_attrs: Vec>, - /// Circuit-level attributes. - pub circuit_attrs: BTreeMap, -} - -impl MetadataStorage { - /// Creates empty metadata storage. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Sets a gate attribute. - pub fn set_gate_attr(&mut self, gate_id: GateId, key: &str, value: Attribute) { - self.gate_attrs - .entry(gate_id) - .or_default() - .insert(key.to_string(), value); - } - - /// Gets a gate attribute. - #[must_use] - pub fn get_gate_attr(&self, gate_id: GateId, key: &str) -> Option<&Attribute> { - self.gate_attrs.get(&gate_id).and_then(|m| m.get(key)) - } - - /// Sets a tick attribute. - pub fn set_tick_attr(&mut self, tick: usize, key: &str, value: Attribute) { - if tick >= self.tick_attrs.len() { - self.tick_attrs.resize(tick + 1, BTreeMap::new()); - } - self.tick_attrs[tick].insert(key.to_string(), value); - } - - /// Gets a tick attribute. - #[must_use] - pub fn get_tick_attr(&self, tick: usize, key: &str) -> Option<&Attribute> { - self.tick_attrs.get(tick).and_then(|m| m.get(key)) - } - - /// Sets a circuit attribute. - pub fn set_circuit_attr(&mut self, key: &str, value: Attribute) { - self.circuit_attrs.insert(key.to_string(), value); - } - - /// Gets a circuit attribute. - #[must_use] - pub fn get_circuit_attr(&self, key: &str) -> Option<&Attribute> { - self.circuit_attrs.get(key) - } - - /// Clears all metadata. - pub fn clear(&mut self) { - self.gate_attrs.clear(); - self.tick_attrs.clear(); - self.circuit_attrs.clear(); - } -} - -// ============================================================================ -// TickCircuitSoA -// ============================================================================ - -/// A tick-based quantum circuit optimized for batched simulation. -/// -/// This is a DOD (Data-Oriented Design) alternative to [`TickCircuit`](crate::TickCircuit) -/// that provides: -/// - **Batched simulation**: Gates grouped by type for efficient batch execution -/// - **Cache-friendly access**: Qubits for same-type gates stored contiguously -/// - **Individual gate access**: `SoA` storage for analysis workloads -#[derive(Debug, Clone, Default)] -pub struct TickCircuitSoA { - /// Gates grouped by type for batched simulation (primary interface). - pub batched: TickGateGroups, - /// Gate data in `SoA` layout (for individual gate access). - pub storage: GateStorage, - /// Pre-computed indexes. - pub indexes: CircuitIndexes, - /// Metadata (lazy allocation). - pub metadata: MetadataStorage, -} - -impl TickCircuitSoA { - /// Creates a new empty circuit. - #[must_use] - pub fn new() -> Self { - Self::default() - } - - /// Creates a builder for constructing circuits with a fluent API. - #[must_use] - pub fn builder() -> TickCircuitSoABuilder { - TickCircuitSoABuilder::new() - } - - /// Returns the number of ticks. - #[inline] - #[must_use] - pub fn num_ticks(&self) -> usize { - self.indexes.num_ticks - } - - /// Returns the total number of active gates. - #[inline] - #[must_use] - pub fn gate_count(&self) -> usize { - self.storage.active_count() - } - - /// Returns the maximum qubit index. - #[inline] - #[must_use] - pub fn max_qubit(&self) -> usize { - self.indexes.max_qubit - } - - /// Returns the gate type for a gate ID. - #[inline] - #[must_use] - pub fn gate_type(&self, id: GateId) -> Option { - self.storage.gate_type(id) - } - - /// Returns the qubits for a gate ID. - #[inline] - #[must_use] - pub fn gate_qubits(&self, id: GateId) -> Option<&[QubitId]> { - self.storage.gate_qubits(id) - } - - /// Returns the angles for a gate ID. - #[inline] - #[must_use] - pub fn gate_angles(&self, id: GateId) -> Option<&[Angle64]> { - self.storage.gate_angles(id) - } - - /// Returns all gates touching a specific qubit. - #[inline] - #[must_use] - pub fn gates_touching_qubit(&self, qubit: usize) -> &[GateId] { - self.indexes.gates_touching_qubit(qubit) - } - - /// Returns all gate indices touching a specific qubit (optimized, no validation). - #[inline] - #[must_use] - pub fn gates_touching_qubit_raw(&self, qubit: usize) -> &[u32] { - self.indexes.gates_touching_qubit_raw(qubit) - } - - /// Returns gate indices in a specific tick (optimized, O(1)). - #[inline] - #[must_use] - pub fn gates_in_tick_raw(&self, tick: usize) -> &[u32] { - self.indexes.gates_in_tick(tick) - } - - /// Returns gates on a qubit sorted by tick (for backward traversal). - #[inline] - #[must_use] - pub fn qubit_gates_sorted(&self, qubit: usize) -> &[(u16, u32)] { - self.indexes.qubit_gates_sorted(qubit) - } - - /// Validates that a gate ID is still valid. - #[inline] - #[must_use] - pub fn is_valid(&self, id: GateId) -> bool { - self.storage.is_valid(id) - } - - /// Iterator over all valid gate IDs. - pub fn iter_gate_ids(&self) -> impl Iterator + '_ { - self.storage.iter_ids() - } - - /// Iterator over gate IDs in a specific tick. - #[allow(clippy::cast_possible_truncation)] // tick index fits in u16 - pub fn gates_in_tick(&self, tick: usize) -> impl Iterator + '_ { - self.storage - .iter_ids() - .filter(move |&id| self.storage.tick_id(id) == Some(tick as u16)) - } - - // ========================================================================= - // Batched Simulation API - // ========================================================================= - - /// Returns an iterator over ticks with batched gates for simulation. - /// - /// This is the primary API for efficient circuit simulation: - /// ```text - /// for (tick_idx, tick) in circuit.iter_ticks_batched() { - /// for batch in tick.iter() { - /// match batch.gate_type { - /// GateType::H => sim.h(batch.qubits()), - /// GateType::CX => sim.cx(batch.qubits()), - /// // ... - /// } - /// } - /// } - /// ``` - #[inline] - pub fn iter_ticks_batched(&self) -> impl Iterator { - self.batched.iter_ticks() - } - - /// Returns the batched gates for a specific tick. - #[inline] - #[must_use] - pub fn tick_batched(&self, tick: usize) -> Option<&TickBatches> { - self.batched.tick(tick) - } - - /// Returns the number of ticks (from batched representation). - #[inline] - #[must_use] - pub fn num_ticks_batched(&self) -> usize { - self.batched.num_ticks() - } - - /// Clears the circuit. - pub fn clear(&mut self) { - self.batched.clear(); - self.storage.clear(); - self.indexes.clear(); - self.metadata.clear(); - } - - /// Rebuilds indexes after modifications. - pub fn rebuild_indexes(&mut self) { - self.indexes.rebuild(&self.storage); - } -} - -// ============================================================================ -// Builder Pattern -// ============================================================================ - -/// Builder for constructing `TickCircuitSoA` with a fluent API. -#[derive(Debug)] -pub struct TickCircuitSoABuilder { - batched: TickGateGroups, - storage: GateStorage, - indexes: CircuitIndexes, - metadata: MetadataStorage, - current_tick: u16, - last_gate_id: Option, -} - -impl TickCircuitSoABuilder { - /// Creates a new builder. - #[must_use] - pub fn new() -> Self { - Self { - batched: TickGateGroups::new(), - storage: GateStorage::new(), - indexes: CircuitIndexes::new(), - metadata: MetadataStorage::new(), - current_tick: 0, - last_gate_id: None, - } - } - - /// Starts a new tick. - pub fn tick(&mut self) -> &mut Self { - // Update num_ticks - self.indexes.num_ticks = (self.current_tick + 1) as usize; - self.current_tick += 1; - self.last_gate_id = None; - self - } - - /// Adds a gate to the current tick. - fn add_gate( - &mut self, - gate_type: GateType, - qubits: &[QubitId], - angles: &[Angle64], - params: &[f64], - ) -> &mut Self { - let tick = self.current_tick.saturating_sub(1); - - // Add to batched representation (primary for simulation) - self.batched - .add_gate(tick as usize, gate_type, qubits, angles); - - // Add to SoA storage (for individual gate access) - let gate_id = self - .storage - .add_gate(gate_type, tick, qubits, angles, params); - - // Update indexes - self.indexes.register_gate_raw(gate_id.index, tick, qubits); - self.last_gate_id = Some(gate_id); - self - } - - /// Sets metadata on the last added gate (or tick if no gate yet). - pub fn meta(&mut self, key: &str, value: impl Into) -> &mut Self { - if let Some(gate_id) = self.last_gate_id { - self.metadata.set_gate_attr(gate_id, key, value.into()); - } else { - // Set tick-level metadata - let tick = self.current_tick.saturating_sub(1) as usize; - self.metadata.set_tick_attr(tick, key, value.into()); - } - self - } - - /// Builds the final circuit. - #[must_use] - pub fn build(mut self) -> TickCircuitSoA { - // Finalize indexes (sort qubit gates by tick for efficient traversal) - self.indexes.finalize(); - TickCircuitSoA { - batched: self.batched, - storage: self.storage, - indexes: self.indexes, - metadata: self.metadata, - } - } - - // ========================================================================= - // Gate methods (mirror TickHandle API) - // ========================================================================= - - /// Apply Hadamard gate(s). - pub fn h(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { - let qs: Vec = qubits.iter().map(|&q| q.into()).collect(); - self.add_gate(GateType::H, &qs, &[], &[]) - } - - /// Apply X gate(s). - pub fn x(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { - let qs: Vec = qubits.iter().map(|&q| q.into()).collect(); - self.add_gate(GateType::X, &qs, &[], &[]) - } - - /// Apply Y gate(s). - pub fn y(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { - let qs: Vec = qubits.iter().map(|&q| q.into()).collect(); - self.add_gate(GateType::Y, &qs, &[], &[]) - } - - /// Apply Z gate(s). - pub fn z(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { - let qs: Vec = qubits.iter().map(|&q| q.into()).collect(); - self.add_gate(GateType::Z, &qs, &[], &[]) - } - - /// Apply SX gate(s). - pub fn sx(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { - let qs: Vec = qubits.iter().map(|&q| q.into()).collect(); - self.add_gate(GateType::SX, &qs, &[], &[]) - } - - /// Apply SY gate(s). - pub fn sy(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { - let qs: Vec = qubits.iter().map(|&q| q.into()).collect(); - self.add_gate(GateType::SY, &qs, &[], &[]) - } - - /// Apply SZ gate(s). - pub fn sz(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { - let qs: Vec = qubits.iter().map(|&q| q.into()).collect(); - self.add_gate(GateType::SZ, &qs, &[], &[]) - } - - /// Apply CX gate(s). - pub fn cx( - &mut self, - pairs: &[(impl Into + Copy, impl Into + Copy)], - ) -> &mut Self { - let qs: Vec = pairs - .iter() - .flat_map(|&(c, t)| [c.into(), t.into()]) - .collect(); - self.add_gate(GateType::CX, &qs, &[], &[]) - } - - /// Apply CZ gate(s). - pub fn cz( - &mut self, - pairs: &[(impl Into + Copy, impl Into + Copy)], - ) -> &mut Self { - let qs: Vec = pairs - .iter() - .flat_map(|&(a, b)| [a.into(), b.into()]) - .collect(); - self.add_gate(GateType::CZ, &qs, &[], &[]) - } - - /// Prepare qubit(s) in |0⟩. - pub fn pz(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { - let qs: Vec = qubits.iter().map(|&q| q.into()).collect(); - self.add_gate(GateType::PZ, &qs, &[], &[]) - } - - /// Measure qubit(s) in Z basis. - pub fn mz(&mut self, qubits: &[impl Into + Copy]) -> &mut Self { - let qs: Vec = qubits.iter().map(|&q| q.into()).collect(); - self.add_gate(GateType::MZ, &qs, &[], &[]) - } -} - -impl Default for TickCircuitSoABuilder { - fn default() -> Self { - Self::new() - } -} - -// ============================================================================ -// Conversion from TickCircuit -// ============================================================================ - -impl From<&crate::TickCircuit> for TickCircuitSoA { - fn from(tc: &crate::TickCircuit) -> Self { - let mut builder = TickCircuitSoABuilder::new(); - - for (tick_idx, tick_data) in tc.iter_ticks() { - // Ensure we're at the right tick - #[allow(clippy::cast_possible_truncation)] // tick index fits in u16 - while builder.current_tick <= tick_idx as u16 { - builder.tick(); - } - - for (gate_idx, gate) in tick_data.gates().iter().enumerate() { - let qubits: Vec = gate.qubits.to_vec(); - let angles: Vec = gate.angles.to_vec(); - let params: Vec = gate.params.to_vec(); - - let tick_num = builder.current_tick.saturating_sub(1); - - // Add to batched representation (primary for simulation) - builder - .batched - .add_gate(tick_num as usize, gate.gate_type, &qubits, &angles); - - // Add to SoA storage (for individual gate access) - let gate_id = - builder - .storage - .add_gate(gate.gate_type, tick_num, &qubits, &angles, ¶ms); - builder.indexes.register_gate(gate_id, &qubits); - builder.indexes.num_ticks = builder.indexes.num_ticks.max(tick_num as usize + 1); - - // Copy gate attributes - for (key, value) in tick_data.gate_attrs(gate_idx) { - builder.metadata.set_gate_attr(gate_id, key, value.clone()); - } - } - - // Copy tick attributes - for (key, value) in tick_data.tick_attrs() { - builder.metadata.set_tick_attr(tick_idx, key, value.clone()); - } - } - - // Copy circuit attributes - for (key, value) in tc.circuit_attrs() { - builder.metadata.set_circuit_attr(key, value.clone()); - } - - builder.build() - } -} - -#[cfg(test)] -mod tests { - use super::*; - - #[test] - fn test_basic_construction() { - let mut builder = TickCircuitSoA::builder(); - builder - .tick() - .pz(&[0, 1]) - .tick() - .h(&[0]) - .cx(&[(0, 1)]) - .tick() - .mz(&[0, 1]); - - let circuit = builder.build(); - - assert_eq!(circuit.num_ticks(), 3); - assert_eq!(circuit.gate_count(), 4); // pz, h, cx, mz - } - - #[test] - fn test_gate_lookup() { - let mut builder = TickCircuitSoA::builder(); - builder.tick().h(&[0]).x(&[1]); - - let circuit = builder.build(); - - // Find all gates - let gate_ids: Vec<_> = circuit.iter_gate_ids().collect(); - assert_eq!(gate_ids.len(), 2); - - // Check gate types - assert_eq!(circuit.gate_type(gate_ids[0]), Some(GateType::H)); - assert_eq!(circuit.gate_type(gate_ids[1]), Some(GateType::X)); - - // Check qubits - assert_eq!( - circuit.gate_qubits(gate_ids[0]), - Some([QubitId::from(0)].as_slice()) - ); - assert_eq!( - circuit.gate_qubits(gate_ids[1]), - Some([QubitId::from(1)].as_slice()) - ); - } - - #[test] - fn test_qubit_index() { - let mut builder = TickCircuitSoA::builder(); - builder.tick().h(&[0]).x(&[1]).tick().cx(&[(0, 1)]); - - let circuit = builder.build(); - - // Gates on qubit 0: H and CX (using raw accessor for efficiency) - let q0_gates = circuit.gates_touching_qubit_raw(0); - assert_eq!(q0_gates.len(), 2); - - // Gates on qubit 1: X and CX - let q1_gates = circuit.gates_touching_qubit_raw(1); - assert_eq!(q1_gates.len(), 2); - - // Gates on qubit 2: none - let q2_gates = circuit.gates_touching_qubit_raw(2); - assert_eq!(q2_gates.len(), 0); - } - - #[test] - fn test_metadata() { - let mut builder = TickCircuitSoA::builder(); - builder - .tick() - .meta("round", Attribute::Int(0)) - .h(&[0]) - .meta("duration", Attribute::Float(50.0)); - - let circuit = builder.build(); - - // Check tick metadata - assert_eq!( - circuit.metadata.get_tick_attr(0, "round"), - Some(&Attribute::Int(0)) - ); - - // Check gate metadata - let gate_ids: Vec<_> = circuit.iter_gate_ids().collect(); - assert_eq!( - circuit.metadata.get_gate_attr(gate_ids[0], "duration"), - Some(&Attribute::Float(50.0)) - ); - } - - #[test] - fn test_gate_removal() { - let mut builder = TickCircuitSoA::builder(); - builder.tick().h(&[0]).x(&[1]); - - let mut circuit = builder.build(); - let gate_ids: Vec<_> = circuit.iter_gate_ids().collect(); - - assert_eq!(circuit.gate_count(), 2); - - // Remove first gate - assert!(circuit.storage.remove(gate_ids[0])); - assert_eq!(circuit.gate_count(), 1); - - // Gate ID is now invalid - assert!(!circuit.is_valid(gate_ids[0])); - assert!(circuit.is_valid(gate_ids[1])); - } - - #[test] - fn test_generational_ids() { - let mut storage = GateStorage::new(); - - // Add and remove a gate - let id1 = storage.add_gate(GateType::H, 0, &[QubitId::from(0)], &[], &[]); - assert!(storage.is_valid(id1)); - - storage.remove(id1); - assert!(!storage.is_valid(id1)); - - // Add another gate - reuses the slot with new generation - let id2 = storage.add_gate(GateType::X, 0, &[QubitId::from(0)], &[], &[]); - assert!(storage.is_valid(id2)); - assert!(!storage.is_valid(id1)); // Old ID still invalid - - // Same index, different generation - assert_eq!(id1.index(), id2.index()); - assert_ne!(id1.generation(), id2.generation()); - } - - #[test] - fn test_batched_simulation_api() { - let mut builder = TickCircuitSoA::builder(); - builder - .tick() - .pz(&[0, 1, 2, 3]) // 4 preps - .tick() - .h(&[0, 1]) // 2 H gates - .x(&[2, 3]) // 2 X gates - .tick() - .cx(&[(0, 1), (2, 3)]) // 2 CX gates - .tick() - .mz(&[0, 1, 2, 3]); // 4 measurements - - let circuit = builder.build(); - - // Check tick count - assert_eq!(circuit.num_ticks_batched(), 4); - - // Check tick 0: preps batched together - let tick0 = circuit.tick_batched(0).unwrap(); - assert_eq!(tick0.batch_count(), 1); - let prep_batch = tick0.batch_for_type(GateType::PZ).unwrap(); - assert_eq!(prep_batch.qubits().len(), 4); - assert_eq!(prep_batch.gate_count(), 4); - - // Check tick 1: H and X are separate batches - let tick1 = circuit.tick_batched(1).unwrap(); - assert_eq!(tick1.batch_count(), 2); - let h_batch = tick1.batch_for_type(GateType::H).unwrap(); - assert_eq!(h_batch.qubits().len(), 2); - let x_batch = tick1.batch_for_type(GateType::X).unwrap(); - assert_eq!(x_batch.qubits().len(), 2); - - // Check tick 2: CX gates batched - let tick2 = circuit.tick_batched(2).unwrap(); - assert_eq!(tick2.batch_count(), 1); - let cx_batch = tick2.batch_for_type(GateType::CX).unwrap(); - assert_eq!(cx_batch.qubits().len(), 4); // 2 pairs = 4 qubits - assert_eq!(cx_batch.gate_count(), 2); - - // Check tick 3: measurements batched - let tick3 = circuit.tick_batched(3).unwrap(); - assert_eq!(tick3.batch_count(), 1); - let mz_batch = tick3.batch_for_type(GateType::MZ).unwrap(); - assert_eq!(mz_batch.qubits().len(), 4); - assert_eq!(mz_batch.gate_count(), 4); - } - - #[test] - fn test_iter_ticks_batched() { - let mut builder = TickCircuitSoA::builder(); - builder.tick().h(&[0, 1, 2]).tick().cx(&[(0, 1)]); - - let circuit = builder.build(); - - // Iterate and count - let mut tick_count = 0; - let mut total_batches = 0; - for (_tick_idx, tick) in circuit.iter_ticks_batched() { - tick_count += 1; - total_batches += tick.batch_count(); - } - - assert_eq!(tick_count, 2); - assert_eq!(total_batches, 2); // H batch + CX batch - } -} diff --git a/crates/pecos-quantum/src/unitary_matrix.rs b/crates/pecos-quantum/src/unitary_matrix.rs index 5a70072ae..27ffbfeea 100644 --- a/crates/pecos-quantum/src/unitary_matrix.rs +++ b/crates/pecos-quantum/src/unitary_matrix.rs @@ -23,7 +23,7 @@ //! //! ``` //! use pecos_quantum::unitary_matrix::ToMatrix; -//! use pecos_core::unitary_rep::X; +//! use pecos_core::unitary::X; //! //! let x = X(0); //! let matrix = x.to_matrix(); // Method style @@ -31,6 +31,9 @@ use nalgebra::DMatrix; use num_complex::Complex64; +use pecos_random::{Rng, RngExt as _}; +use std::error::Error; +use std::f64::consts::TAU; use std::fmt; use std::ops::{BitAnd, Deref, DerefMut, Mul, Neg, Sub}; use std::sync::LazyLock; @@ -56,7 +59,7 @@ use pecos_core::{Angle64, Op, Pauli, PauliString, Phase}; /// /// ``` /// use pecos_quantum::unitary_matrix::{UnitaryMatrix, ToMatrix}; -/// use pecos_core::unitary_rep::{X, Z}; +/// use pecos_core::unitary::{X, Z}; /// /// let mx = X(0).to_matrix(); /// let mz = Z(0).to_matrix(); @@ -70,6 +73,30 @@ use pecos_core::{Angle64, Op, Pauli, PauliString, Phase}; #[derive(Debug, Clone, PartialEq)] pub struct UnitaryMatrix(pub DMatrix); +/// Error returned by dense unitary-matrix helpers. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum UnitaryMatrixError { + /// The requested number of qubits would overflow a dense Hilbert-space + /// dimension. + DimensionOverflow { + /// Number of qubits supplied by the caller. + num_qubits: usize, + }, +} + +impl fmt::Display for UnitaryMatrixError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DimensionOverflow { num_qubits } => write!( + f, + "dense unitary dimension overflows usize for {num_qubits} qubits" + ), + } + } +} + +impl Error for UnitaryMatrixError {} + impl UnitaryMatrix { /// Creates an identity matrix of size `n x n`. #[must_use] @@ -192,6 +219,70 @@ impl UnitaryMatrix { } } +/// Returns a Haar-random dense unitary on `num_qubits` qubits. +/// +/// The implementation samples a complex Ginibre matrix, performs QR +/// decomposition, and fixes the column phases from the `R` diagonal. The output +/// is a dense matrix in the same little-endian computational-basis convention as +/// the rest of PECOS's matrix helpers. +/// +/// # Errors +/// +/// Returns [`UnitaryMatrixError::DimensionOverflow`] if `2^num_qubits` does not +/// fit in `usize`. +pub fn random_unitary( + rng: &mut R, + num_qubits: usize, +) -> Result +where + R: Rng + ?Sized, +{ + let dim = dense_hilbert_dim(num_qubits)?; + let ginibre = DMatrix::from_fn(dim, dim, |_, _| standard_complex_normal(rng)); + let (mut q, r) = ginibre.qr().unpack(); + + for col in 0..dim { + let diagonal = r[(col, col)]; + let norm = diagonal.norm(); + if norm > 0.0 { + let phase = diagonal / norm; + for row in 0..dim { + q[(row, col)] *= phase; + } + } + } + + Ok(UnitaryMatrix(q)) +} + +fn dense_hilbert_dim(num_qubits: usize) -> Result { + let exponent = u32::try_from(num_qubits) + .map_err(|_| UnitaryMatrixError::DimensionOverflow { num_qubits })?; + 2usize + .checked_pow(exponent) + .ok_or(UnitaryMatrixError::DimensionOverflow { num_qubits }) +} + +fn standard_complex_normal(rng: &mut R) -> Complex64 +where + R: Rng + ?Sized, +{ + Complex64::new(standard_normal(rng), standard_normal(rng)) +} + +fn standard_normal(rng: &mut R) -> f64 +where + R: Rng + ?Sized, +{ + loop { + let u1 = rng.random::(); + if u1 > 0.0 { + let u2 = rng.random::(); + return (-2.0 * u1.ln()).sqrt() * (TAU * u2).cos(); + } + } +} + // --- Canonicalization and cached lookup tables --- /// Divides all entries by the first nonzero entry (row-major scan). @@ -1048,7 +1139,7 @@ impl fmt::Display for UnitaryMatrix { /// /// ``` /// use pecos_quantum::unitary_matrix::ToMatrix; -/// use pecos_core::unitary_rep::{X, H, CX, Is}; +/// use pecos_core::unitary::{X, H, CX, Is}; /// /// // Single qubit gate /// let x_matrix = X(0).to_matrix(); @@ -1123,13 +1214,17 @@ impl ToMatrix for Unitary { } impl ToMatrix for Op { - /// Converts to a matrix. Returns the zero matrix for channels (non-unitary ops). + /// Converts to a matrix. + /// + /// # Panics + /// + /// Panics for Gate-level or Channel-level operations, which do not have a + /// unitary matrix representation. fn to_matrix(&self) -> UnitaryMatrix { match self.clone().into_unitary() { Some(ur) => to_matrix(&ur), None => { - // Channel ops don't have a unitary matrix - panic!("Cannot convert non-unitary Op (Channel) to a matrix") + panic!("Cannot convert non-unitary Op (Gate/Channel) to a matrix") } } } @@ -1144,7 +1239,7 @@ impl ToMatrix for Op { /// /// ``` /// use pecos_quantum::unitary_matrix::to_matrix; -/// use pecos_core::unitary_rep::X; +/// use pecos_core::unitary::X; /// use num_complex::Complex64; /// /// let x = X(0); @@ -1170,6 +1265,24 @@ pub fn to_matrix_with_size(op: &UnitaryRep, num_qubits: usize) -> UnitaryMatrix UnitaryMatrix(to_matrix_with_size_impl(op, num_qubits)) } +fn assert_tensor_parts_have_disjoint_support(parts: &[UnitaryRep]) { + let mut used = std::collections::BTreeSet::new(); + for part in parts { + let mut overlap = Vec::new(); + for q in part.qubits() { + if used.contains(&q) { + overlap.push(q); + } else { + used.insert(q); + } + } + assert!( + overlap.is_empty(), + "tensor product requires disjoint unitary support; overlapping qubits: {overlap:?}" + ); + } +} + /// Internal implementation that returns raw `DMatrix` for recursive use. fn to_matrix_with_size_impl(op: &UnitaryRep, num_qubits: usize) -> DMatrix { let dim = 1 << num_qubits; // 2^num_qubits @@ -1211,6 +1324,8 @@ fn to_matrix_with_size_impl(op: &UnitaryRep, num_qubits: usize) -> DMatrix { + assert_tensor_parts_have_disjoint_support(parts); + // Start with identity, combine each part let mut result = DMatrix::identity(dim, dim); for part in parts { @@ -1252,7 +1367,7 @@ fn to_matrix_with_size_impl(op: &UnitaryRep, num_qubits: usize) -> DMatrix UnitaryMatrix { /// /// ``` /// use pecos_quantum::unitary_matrix::{unitary_log, to_matrix}; -/// use pecos_core::unitary_rep::X; +/// use pecos_core::unitary::X; /// /// let x = X(0); /// if let Some(log_x) = unitary_log(&x) { @@ -1300,7 +1415,7 @@ pub fn unitary_log(op: &UnitaryRep) -> Option> { /// /// ``` /// use pecos_quantum::unitary_matrix::unitaries_equiv; -/// use pecos_core::unitary_rep::{X, Y, Z}; +/// use pecos_core::unitary::{X, Y, Z}; /// /// let x = X(0); /// let x2 = X(0); @@ -1834,7 +1949,9 @@ fn gate_to_matrix(gate_type: GateType, qubits: &[usize], num_qubits: usize) -> D GateType::Idle | GateType::MeasCrosstalkGlobalPayload | GateType::MeasCrosstalkLocalPayload - | GateType::Custom => { + | GateType::Channel + | GateType::Custom + | GateType::TrackedPauliMeta => { panic!("GateType::{gate_type:?} cannot be converted to a unitary matrix") } } @@ -1916,6 +2033,7 @@ mod tests { use super::*; use pecos_core::Angle64; use pecos_core::unitary_rep::{CX, H, I, Is, RX, RZ, SWAP, SZ, T, X, Y, Z}; + use pecos_random::PecosRng; use std::f64::consts::PI; // --- Basic to_matrix tests --- @@ -2042,6 +2160,40 @@ mod tests { assert!(matrices_equiv_up_to_phase(&mat, &expected, 1e-10)); } + fn assert_tensor_matrix_matches_embedded_product( + lhs: &pecos_core::unitary_rep::UnitaryRep, + rhs: &pecos_core::unitary_rep::UnitaryRep, + num_qubits: usize, + ) { + let tensor = lhs.clone() & rhs.clone(); + let tensor_matrix = to_matrix_with_size(&tensor, num_qubits); + let lhs_matrix = to_matrix_with_size(lhs, num_qubits); + let rhs_matrix = to_matrix_with_size(rhs, num_qubits); + let expected = &lhs_matrix * &rhs_matrix; + + assert!( + matrices_equiv_up_to_phase(&tensor_matrix, &expected, 1e-10), + "{tensor:?} matrix did not match embedded product" + ); + } + + #[test] + fn test_disjoint_tensor_matrix_semantics_across_operator_levels() { + assert_tensor_matrix_matches_embedded_product(&X(0), &Z(1), 2); + assert_tensor_matrix_matches_embedded_product(&H(0), &SZ(1), 2); + assert_tensor_matrix_matches_embedded_product(&H(0), &T(1), 2); + assert_tensor_matrix_matches_embedded_product(&CX(0, 2), &T(1), 3); + assert_tensor_matrix_matches_embedded_product(&H(3), &CX(0, 2), 4); + } + + #[test] + #[should_panic(expected = "tensor product requires disjoint unitary support")] + fn test_to_matrix_rejects_invalid_overlapping_tensor_node() { + let invalid = pecos_core::unitary_rep::UnitaryRep::Tensor(vec![X(0), Z(0)]); + + let _ = to_matrix(&invalid); + } + #[test] fn test_composition() { // H * X = XH (matrix multiplication order) @@ -3112,6 +3264,32 @@ mod tests { assert!(!mat.is_unitary()); } + #[test] + fn random_unitary_is_unitary_and_seed_reproducible() { + let mut rng = PecosRng::seed_from_u64(123); + let unitary = random_unitary(&mut rng, 2).unwrap(); + assert_eq!(unitary.inner().shape(), (4, 4)); + assert!(unitary.is_unitary_with_tolerance(1e-10)); + + let mut same_seed = PecosRng::seed_from_u64(123); + let same = random_unitary(&mut same_seed, 2).unwrap(); + assert_eq!(unitary.inner(), same.inner()); + + let mut different_seed = PecosRng::seed_from_u64(456); + let different = random_unitary(&mut different_seed, 2).unwrap(); + assert!( + !matrices_approx_equal(unitary.inner(), different.inner(), 1e-8), + "different seeds should not produce the same Haar draw" + ); + } + + #[test] + fn random_unitary_dimension_overflow_is_reported() { + let mut rng = PecosRng::seed_from_u64(123); + let err = random_unitary(&mut rng, usize::BITS as usize).unwrap_err(); + assert!(matches!(err, UnitaryMatrixError::DimensionOverflow { .. })); + } + #[test] fn try_to_unitary_rxxryyrzz_stress() { // Test a grid of angle combinations including edge cases: diff --git a/crates/pecos-relay-bp/src/core_traits.rs b/crates/pecos-relay-bp/src/core_traits.rs index ca546425f..208eaeb43 100644 --- a/crates/pecos-relay-bp/src/core_traits.rs +++ b/crates/pecos-relay-bp/src/core_traits.rs @@ -21,6 +21,10 @@ impl DecodingResultTrait for DecodingResult { self.converged } + fn correction(&self) -> &[u8] { + self.decoding.as_slice().unwrap_or(&[]) + } + fn cost(&self) -> Option { None } diff --git a/crates/pecos-simulators/Cargo.toml b/crates/pecos-simulators/Cargo.toml index 794ce21b9..b4f4ac594 100644 --- a/crates/pecos-simulators/Cargo.toml +++ b/crates/pecos-simulators/Cargo.toml @@ -18,11 +18,6 @@ default = [] # Do NOT enable for multi-shot workloads where shots are parallelized at the # orchestration level - this causes thread oversubscription and hurts throughput. parallel = ["rayon"] -# Enable AVX-512 SIMD operations using f64x8 (512-bit vectors processing 8 f64 at once). -# Requires AVX-512 capable CPU (Intel Ice Lake+, AMD Zen 4+). -# Build with: RUSTFLAGS='-C target-feature=+avx512f' cargo build --release --features avx512 -# Provides ~2x theoretical speedup for SIMD-bound operations on supported hardware. -avx512 = [] [dependencies] pecos-core.workspace = true @@ -30,6 +25,7 @@ pecos-quantum.workspace = true pecos-random.workspace = true rand.workspace = true num-complex.workspace = true +nalgebra.workspace = true smallvec.workspace = true wide.workspace = true rayon = { version = "1.10", optional = true } diff --git a/crates/pecos-simulators/src/bitmask_pauli_prop.rs b/crates/pecos-simulators/src/bitmask_pauli_prop.rs new file mode 100644 index 000000000..2fead22f4 --- /dev/null +++ b/crates/pecos-simulators/src/bitmask_pauli_prop.rs @@ -0,0 +1,1173 @@ +// 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. + +//! Bitmask-backed Pauli propagation for hot Clifford fault-analysis paths. +//! +//! This type tracks only the binary X/Z support of a propagating Pauli. It +//! intentionally ignores global phase, matching the fault-catalog use case +//! where only measurement flips and anticommutation with tracked Paulis +//! matter. + +use crate::clifford_gateable::{CliffordGateable, MeasurementResult}; +use crate::quantum_simulator::QuantumSimulator; +use pecos_core::{BitmaskStorage, PauliBitmaskSmall, QubitId}; + +/// Internal phase-free Pauli propagator backed by `PauliBitmaskSmall`. +/// +/// This is a performance helper for fault analysis and other hot internal +/// propagation paths. User-facing code should prefer Pauli strings and the +/// standard simulator APIs. +#[doc(hidden)] +#[derive(Clone, Debug)] +pub struct BitmaskPauliProp { + label: PauliBitmaskSmall, + num_qubits: usize, +} + +impl Default for BitmaskPauliProp { + fn default() -> Self { + Self::new() + } +} + +impl BitmaskPauliProp { + /// Create an empty propagating Pauli with no fixed qubit count. + #[must_use] + pub fn new() -> Self { + Self { + label: PauliBitmaskSmall::identity(), + num_qubits: 0, + } + } + + /// Create an empty propagating Pauli with a fixed qubit count for display + /// and tests. + #[must_use] + pub fn with_num_qubits(num_qubits: usize) -> Self { + Self { + label: PauliBitmaskSmall::identity(), + num_qubits, + } + } + + /// Checks whether the specified qubit has an X component. + #[inline] + #[must_use] + pub fn contains_x(&self, qubit: usize) -> bool { + self.label.x_bits.get_bit(qubit) + } + + /// Checks whether the specified qubit has a Z component. + #[inline] + #[must_use] + pub fn contains_z(&self, qubit: usize) -> bool { + self.label.z_bits.get_bit(qubit) + } + + /// Checks whether the specified qubit has a Y component. + #[inline] + #[must_use] + pub fn contains_y(&self, qubit: usize) -> bool { + self.contains_x(qubit) && self.contains_z(qubit) + } + + /// Toggle X components on the given qubits. + #[inline] + pub fn track_x(&mut self, qubits: &[usize]) { + for &q in qubits { + self.label.x_bits.xor_bit(q); + self.num_qubits = self.num_qubits.max(q + 1); + } + } + + /// Toggle Z components on the given qubits. + #[inline] + pub fn track_z(&mut self, qubits: &[usize]) { + for &q in qubits { + self.label.z_bits.xor_bit(q); + self.num_qubits = self.num_qubits.max(q + 1); + } + } + + /// Toggle Y components on the given qubits. + #[inline] + pub fn track_y(&mut self, qubits: &[usize]) { + for &q in qubits { + self.label.x_bits.xor_bit(q); + self.label.z_bits.xor_bit(q); + self.num_qubits = self.num_qubits.max(q + 1); + } + } + + /// Remove all Pauli components from one qubit. + #[inline] + pub fn clear_qubit(&mut self, qubit: usize) { + self.label.x_bits.clear_bit(qubit); + self.label.z_bits.clear_bit(qubit); + } + + /// True when no X/Z components remain. + #[inline] + #[must_use] + pub fn is_identity(&self) -> bool { + self.label.is_identity() + } + + /// Number of non-identity single-qubit factors. + #[must_use] + pub fn weight(&self) -> usize { + self.label.weight() as usize + } + + /// Dense string representation in qubit-index order. + #[must_use] + pub fn dense_string(&self) -> String { + let mut result = String::with_capacity(self.num_qubits); + for q in 0..self.num_qubits { + match (self.contains_x(q), self.contains_z(q)) { + (false, false) => result.push('I'), + (true, false) => result.push('X'), + (false, true) => result.push('Z'), + (true, true) => result.push('Y'), + } + } + result + } + + #[inline] + fn set_x_component(&mut self, q: usize, value: bool) { + if value { + self.label.x_bits.set_bit(q); + } else { + self.label.x_bits.clear_bit(q); + } + self.num_qubits = self.num_qubits.max(q + 1); + } + + #[inline] + fn set_z_component(&mut self, q: usize, value: bool) { + if value { + self.label.z_bits.set_bit(q); + } else { + self.label.z_bits.clear_bit(q); + } + self.num_qubits = self.num_qubits.max(q + 1); + } + + #[inline] + fn set_components(&mut self, q: usize, x: bool, z: bool) { + self.set_x_component(q, x); + self.set_z_component(q, z); + } +} + +impl QuantumSimulator for BitmaskPauliProp { + #[inline] + fn reset(&mut self) -> &mut Self { + self.label = PauliBitmaskSmall::identity(); + self + } + + fn num_qubits(&self) -> usize { + self.num_qubits + } +} + +impl CliffordGateable for BitmaskPauliProp { + #[inline] + fn sz(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + let q = q.index(); + if self.contains_x(q) { + self.label.z_bits.xor_bit(q); + } + self.num_qubits = self.num_qubits.max(q + 1); + } + self + } + + #[inline] + fn szdg(&mut self, qubits: &[QubitId]) -> &mut Self { + self.sz(qubits) + } + + #[inline] + fn h(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + let q = q.index(); + let x = self.contains_x(q); + let z = self.contains_z(q); + self.set_components(q, z, x); + } + self + } + + #[inline] + fn sx(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + let q = q.index(); + if self.contains_z(q) { + self.label.x_bits.xor_bit(q); + } + self.num_qubits = self.num_qubits.max(q + 1); + } + self + } + + #[inline] + fn sxdg(&mut self, qubits: &[QubitId]) -> &mut Self { + self.sx(qubits) + } + + #[inline] + fn sy(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + let q = q.index(); + let x = self.contains_x(q); + let z = self.contains_z(q); + self.set_components(q, z, x); + } + self + } + + #[inline] + fn sydg(&mut self, qubits: &[QubitId]) -> &mut Self { + self.sy(qubits) + } + + #[inline] + fn cx(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(control, target) in pairs { + let control = control.index(); + let target = target.index(); + let control_x = self.contains_x(control); + let target_z = self.contains_z(target); + if control_x { + self.label.x_bits.xor_bit(target); + } + if target_z { + self.label.z_bits.xor_bit(control); + } + self.num_qubits = self.num_qubits.max(control.max(target) + 1); + } + self + } + + #[inline] + fn cy(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + self.set_components(q1, x1, z1 ^ x2 ^ z2); + self.set_components(q2, x2 ^ x1, z2 ^ x1); + } + self + } + + #[inline] + fn cz(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + self.set_components(q1, x1, z1 ^ x2); + self.set_components(q2, x2, z2 ^ x1); + } + self + } + + #[inline] + fn sxx(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + let affected = z1 ^ z2; + self.set_components(q1, x1 ^ affected, z1); + self.set_components(q2, x2 ^ affected, z2); + } + self + } + + #[inline] + fn sxxdg(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + self.sxx(pairs) + } + + #[inline] + fn syy(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + self.set_components(q1, x2 ^ z1 ^ z2, x1 ^ x2 ^ z2); + self.set_components(q2, x1 ^ z1 ^ z2, x1 ^ x2 ^ z1); + } + self + } + + #[inline] + fn syydg(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + self.syy(pairs) + } + + #[inline] + fn szz(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + let affected = x1 ^ x2; + self.set_components(q1, x1, z1 ^ affected); + self.set_components(q2, x2, z2 ^ affected); + } + self + } + + #[inline] + fn szzdg(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + self.szz(pairs) + } + + #[inline] + fn swap(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + self.set_components(q1, x2, z2); + self.set_components(q2, x1, z1); + } + self + } + + #[inline] + fn mz(&mut self, qubits: &[QubitId]) -> Vec { + qubits + .iter() + .map(|&q| MeasurementResult { + outcome: self.contains_x(q.index()), + is_deterministic: true, + }) + .collect() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::pauli_prop::PauliProp; + use pecos_core::QubitId; + + fn all_paulis(num_qubits: usize) -> Vec { + let labels = ['I', 'X', 'Y', 'Z']; + let mut out = Vec::new(); + let total = 4usize.pow(num_qubits.try_into().expect("test qubit count fits")); + for mut value in 0..total { + let mut s = String::with_capacity(num_qubits); + for _ in 0..num_qubits { + s.push(labels[value % 4]); + value /= 4; + } + out.push(s); + } + out + } + + fn sparse_prop_from_dense(input: &str) -> PauliProp { + let mut prop = PauliProp::with_sign_tracking(input.len()); + for (q, p) in input.chars().enumerate() { + match p { + 'I' => {} + 'X' => prop.track_x(&[q]), + 'Y' => prop.track_y(&[q]), + 'Z' => prop.track_z(&[q]), + _ => panic!("invalid Pauli label {p}"), + } + } + prop + } + + fn bitmask_prop_from_dense(input: &str) -> BitmaskPauliProp { + let mut prop = BitmaskPauliProp::with_num_qubits(input.len()); + for (q, p) in input.chars().enumerate() { + match p { + 'I' => {} + 'X' => prop.track_x(&[q]), + 'Y' => prop.track_y(&[q]), + 'Z' => prop.track_z(&[q]), + _ => panic!("invalid Pauli label {p}"), + } + } + prop + } + + fn assert_matches_sparse_1q(name: &str, mut apply_sparse: F, mut apply_bitmask: G) + where + F: FnMut(&mut PauliProp), + G: FnMut(&mut BitmaskPauliProp), + { + for input in all_paulis(1) { + let mut sparse = sparse_prop_from_dense(&input); + let mut bitmask = bitmask_prop_from_dense(&input); + apply_sparse(&mut sparse); + apply_bitmask(&mut bitmask); + assert_eq!( + bitmask.dense_string(), + sparse.dense_string(), + "{name}: {input}" + ); + } + } + + fn assert_matches_sparse_2q(name: &str, mut apply_sparse: F, mut apply_bitmask: G) + where + F: FnMut(&mut PauliProp, &[(QubitId, QubitId)]), + G: FnMut(&mut BitmaskPauliProp, &[(QubitId, QubitId)]), + { + let pair = [(QubitId(0), QubitId(1))]; + for input in all_paulis(2) { + let mut sparse = sparse_prop_from_dense(&input); + let mut bitmask = bitmask_prop_from_dense(&input); + apply_sparse(&mut sparse, &pair); + apply_bitmask(&mut bitmask, &pair); + assert_eq!( + bitmask.dense_string(), + sparse.dense_string(), + "{name}: {input}" + ); + } + } + + fn assert_matches_sparse_2q_at_pair( + name: &str, + pair: (QubitId, QubitId), + num_qubits: usize, + mut apply_sparse: F, + mut apply_bitmask: G, + ) where + F: FnMut(&mut PauliProp, &[(QubitId, QubitId)]), + G: FnMut(&mut BitmaskPauliProp, &[(QubitId, QubitId)]), + { + let labels = ['I', 'X', 'Y', 'Z']; + let pairs = [pair]; + for lhs in labels { + for rhs in labels { + let mut input = vec!['I'; num_qubits]; + input[pair.0.0] = lhs; + input[pair.1.0] = rhs; + let input = input.into_iter().collect::(); + + let mut sparse = sparse_prop_from_dense(&input); + let mut bitmask = bitmask_prop_from_dense(&input); + apply_sparse(&mut sparse, &pairs); + apply_bitmask(&mut bitmask, &pairs); + assert_eq!( + bitmask.dense_string(), + sparse.dense_string(), + "{name}: pair {pair:?}, input {input}" + ); + } + } + } + + fn assert_phase_free_1q_table(name: &str, mut apply: F, table: &[(&str, &str)]) + where + F: FnMut(&mut BitmaskPauliProp), + { + for &(input, expected) in table { + let mut prop = bitmask_prop_from_dense(input); + apply(&mut prop); + assert_eq!(prop.dense_string(), expected, "{name}: {input}"); + } + } + + fn assert_phase_free_2q_table(name: &str, mut apply: F, table: &[(&str, &str)]) + where + F: FnMut(&mut BitmaskPauliProp), + { + for &(input, expected) in table { + let mut prop = bitmask_prop_from_dense(input); + apply(&mut prop); + assert_eq!(prop.dense_string(), expected, "{name}: {input}"); + } + } + + #[test] + fn single_qubit_cliffords_match_sparse_pauli_prop() { + assert_matches_sparse_1q( + "H", + |p| { + p.h(&[QubitId(0)]); + }, + |p| { + p.h(&[QubitId(0)]); + }, + ); + assert_matches_sparse_1q( + "SZ", + |p| { + p.sz(&[QubitId(0)]); + }, + |p| { + p.sz(&[QubitId(0)]); + }, + ); + assert_matches_sparse_1q( + "SZdg", + |p| { + p.szdg(&[QubitId(0)]); + }, + |p| { + p.szdg(&[QubitId(0)]); + }, + ); + assert_matches_sparse_1q( + "SX", + |p| { + p.sx(&[QubitId(0)]); + }, + |p| { + p.sx(&[QubitId(0)]); + }, + ); + assert_matches_sparse_1q( + "SXdg", + |p| { + p.sxdg(&[QubitId(0)]); + }, + |p| { + p.sxdg(&[QubitId(0)]); + }, + ); + assert_matches_sparse_1q( + "SY", + |p| { + p.sy(&[QubitId(0)]); + }, + |p| { + p.sy(&[QubitId(0)]); + }, + ); + assert_matches_sparse_1q( + "SYdg", + |p| { + p.sydg(&[QubitId(0)]); + }, + |p| { + p.sydg(&[QubitId(0)]); + }, + ); + assert_matches_sparse_1q( + "F", + |p| { + p.f(&[QubitId(0)]); + }, + |p| { + p.f(&[QubitId(0)]); + }, + ); + assert_matches_sparse_1q( + "Fdg", + |p| { + p.fdg(&[QubitId(0)]); + }, + |p| { + p.fdg(&[QubitId(0)]); + }, + ); + } + + #[test] + fn standard_cliffords_match_phase_free_pauli_tables() { + const PAULI_SELF: &[(&str, &str)] = &[("I", "I"), ("X", "X"), ("Y", "Y"), ("Z", "Z")]; + const H_SY: &[(&str, &str)] = &[("I", "I"), ("X", "Z"), ("Y", "Y"), ("Z", "X")]; + const SZ: &[(&str, &str)] = &[("I", "I"), ("X", "Y"), ("Y", "X"), ("Z", "Z")]; + const SX: &[(&str, &str)] = &[("I", "I"), ("X", "X"), ("Y", "Z"), ("Z", "Y")]; + const F: &[(&str, &str)] = &[("I", "I"), ("X", "Y"), ("Y", "Z"), ("Z", "X")]; + const FDG: &[(&str, &str)] = &[("I", "I"), ("X", "Z"), ("Y", "X"), ("Z", "Y")]; + const CX: &[(&str, &str)] = &[ + ("XI", "XX"), + ("YI", "YX"), + ("ZI", "ZI"), + ("IX", "IX"), + ("IY", "ZY"), + ("IZ", "ZZ"), + ]; + const CY: &[(&str, &str)] = &[ + ("XI", "XY"), + ("YI", "YY"), + ("ZI", "ZI"), + ("IX", "ZX"), + ("IY", "IY"), + ("IZ", "ZZ"), + ]; + const CZ: &[(&str, &str)] = &[ + ("XI", "XZ"), + ("YI", "YZ"), + ("ZI", "ZI"), + ("IX", "ZX"), + ("IY", "ZY"), + ("IZ", "IZ"), + ]; + const SXX: &[(&str, &str)] = &[ + ("XI", "XI"), + ("YI", "ZX"), + ("ZI", "YX"), + ("IX", "IX"), + ("IY", "XZ"), + ("IZ", "XY"), + ]; + const SYY: &[(&str, &str)] = &[ + ("XI", "ZY"), + ("YI", "YI"), + ("ZI", "XY"), + ("IX", "YZ"), + ("IY", "IY"), + ("IZ", "YX"), + ]; + const SZZ: &[(&str, &str)] = &[ + ("XI", "YZ"), + ("YI", "XZ"), + ("ZI", "ZI"), + ("IX", "ZY"), + ("IY", "ZX"), + ("IZ", "IZ"), + ]; + const SWAP: &[(&str, &str)] = &[ + ("XI", "IX"), + ("YI", "IY"), + ("ZI", "IZ"), + ("IX", "XI"), + ("IY", "YI"), + ("IZ", "ZI"), + ]; + + assert_phase_free_1q_table( + "X", + |p| { + p.x(&[QubitId(0)]); + }, + PAULI_SELF, + ); + assert_phase_free_1q_table( + "Y", + |p| { + p.y(&[QubitId(0)]); + }, + PAULI_SELF, + ); + assert_phase_free_1q_table( + "Z", + |p| { + p.z(&[QubitId(0)]); + }, + PAULI_SELF, + ); + assert_phase_free_1q_table( + "H", + |p| { + p.h(&[QubitId(0)]); + }, + H_SY, + ); + assert_phase_free_1q_table( + "SZ", + |p| { + p.sz(&[QubitId(0)]); + }, + SZ, + ); + assert_phase_free_1q_table( + "SZdg", + |p| { + p.szdg(&[QubitId(0)]); + }, + SZ, + ); + assert_phase_free_1q_table( + "SX", + |p| { + p.sx(&[QubitId(0)]); + }, + SX, + ); + assert_phase_free_1q_table( + "SXdg", + |p| { + p.sxdg(&[QubitId(0)]); + }, + SX, + ); + assert_phase_free_1q_table( + "SY", + |p| { + p.sy(&[QubitId(0)]); + }, + H_SY, + ); + assert_phase_free_1q_table( + "SYdg", + |p| { + p.sydg(&[QubitId(0)]); + }, + H_SY, + ); + assert_phase_free_1q_table( + "F", + |p| { + p.f(&[QubitId(0)]); + }, + F, + ); + assert_phase_free_1q_table( + "Fdg", + |p| { + p.fdg(&[QubitId(0)]); + }, + FDG, + ); + + let pair = [(QubitId(0), QubitId(1))]; + assert_phase_free_2q_table( + "CX", + |p| { + p.cx(&pair); + }, + CX, + ); + assert_phase_free_2q_table( + "CY", + |p| { + p.cy(&pair); + }, + CY, + ); + assert_phase_free_2q_table( + "CZ", + |p| { + p.cz(&pair); + }, + CZ, + ); + assert_phase_free_2q_table( + "SXX", + |p| { + p.sxx(&pair); + }, + SXX, + ); + assert_phase_free_2q_table( + "SXXdg", + |p| { + p.sxxdg(&pair); + }, + SXX, + ); + assert_phase_free_2q_table( + "SYY", + |p| { + p.syy(&pair); + }, + SYY, + ); + assert_phase_free_2q_table( + "SYYdg", + |p| { + p.syydg(&pair); + }, + SYY, + ); + assert_phase_free_2q_table( + "SZZ", + |p| { + p.szz(&pair); + }, + SZZ, + ); + assert_phase_free_2q_table( + "SZZdg", + |p| { + p.szzdg(&pair); + }, + SZZ, + ); + assert_phase_free_2q_table( + "SWAP", + |p| { + p.swap(&pair); + }, + SWAP, + ); + } + + #[test] + fn two_qubit_cliffords_match_sparse_pauli_prop() { + assert_matches_sparse_2q( + "CX", + |p, qs| { + p.cx(qs); + }, + |p, qs| { + p.cx(qs); + }, + ); + assert_matches_sparse_2q( + "CY", + |p, qs| { + p.cy(qs); + }, + |p, qs| { + p.cy(qs); + }, + ); + assert_matches_sparse_2q( + "CZ", + |p, qs| { + p.cz(qs); + }, + |p, qs| { + p.cz(qs); + }, + ); + assert_matches_sparse_2q( + "SXX", + |p, qs| { + p.sxx(qs); + }, + |p, qs| { + p.sxx(qs); + }, + ); + assert_matches_sparse_2q( + "SXXdg", + |p, qs| { + p.sxxdg(qs); + }, + |p, qs| { + p.sxxdg(qs); + }, + ); + assert_matches_sparse_2q( + "SYY", + |p, qs| { + p.syy(qs); + }, + |p, qs| { + p.syy(qs); + }, + ); + assert_matches_sparse_2q( + "SYYdg", + |p, qs| { + p.syydg(qs); + }, + |p, qs| { + p.syydg(qs); + }, + ); + assert_matches_sparse_2q( + "SZZ", + |p, qs| { + p.szz(qs); + }, + |p, qs| { + p.szz(qs); + }, + ); + assert_matches_sparse_2q( + "SZZdg", + |p, qs| { + p.szzdg(qs); + }, + |p, qs| { + p.szzdg(qs); + }, + ); + assert_matches_sparse_2q( + "SWAP", + |p, qs| { + p.swap(qs); + }, + |p, qs| { + p.swap(qs); + }, + ); + assert_matches_sparse_2q( + "ISWAP", + |p, qs| { + p.iswap(qs); + }, + |p, qs| { + p.iswap(qs); + }, + ); + assert_matches_sparse_2q( + "ISWAPdg", + |p, qs| { + p.iswapdg(qs); + }, + |p, qs| { + p.iswapdg(qs); + }, + ); + } + + #[test] + fn two_qubit_cliffords_match_sparse_pauli_prop_across_word_boundaries() { + for pair in [ + (QubitId(63), QubitId(64)), + (QubitId(64), QubitId(63)), + (QubitId(64), QubitId(65)), + ] { + assert_matches_sparse_2q_at_pair( + "CX", + pair, + 66, + |p, qs| { + p.cx(qs); + }, + |p, qs| { + p.cx(qs); + }, + ); + assert_matches_sparse_2q_at_pair( + "CY", + pair, + 66, + |p, qs| { + p.cy(qs); + }, + |p, qs| { + p.cy(qs); + }, + ); + assert_matches_sparse_2q_at_pair( + "CZ", + pair, + 66, + |p, qs| { + p.cz(qs); + }, + |p, qs| { + p.cz(qs); + }, + ); + assert_matches_sparse_2q_at_pair( + "SXX", + pair, + 66, + |p, qs| { + p.sxx(qs); + }, + |p, qs| { + p.sxx(qs); + }, + ); + assert_matches_sparse_2q_at_pair( + "SXXdg", + pair, + 66, + |p, qs| { + p.sxxdg(qs); + }, + |p, qs| { + p.sxxdg(qs); + }, + ); + assert_matches_sparse_2q_at_pair( + "SYY", + pair, + 66, + |p, qs| { + p.syy(qs); + }, + |p, qs| { + p.syy(qs); + }, + ); + assert_matches_sparse_2q_at_pair( + "SYYdg", + pair, + 66, + |p, qs| { + p.syydg(qs); + }, + |p, qs| { + p.syydg(qs); + }, + ); + assert_matches_sparse_2q_at_pair( + "SZZ", + pair, + 66, + |p, qs| { + p.szz(qs); + }, + |p, qs| { + p.szz(qs); + }, + ); + assert_matches_sparse_2q_at_pair( + "SZZdg", + pair, + 66, + |p, qs| { + p.szzdg(qs); + }, + |p, qs| { + p.szzdg(qs); + }, + ); + assert_matches_sparse_2q_at_pair( + "SWAP", + pair, + 66, + |p, qs| { + p.swap(qs); + }, + |p, qs| { + p.swap(qs); + }, + ); + assert_matches_sparse_2q_at_pair( + "ISWAP", + pair, + 66, + |p, qs| { + p.iswap(qs); + }, + |p, qs| { + p.iswap(qs); + }, + ); + assert_matches_sparse_2q_at_pair( + "ISWAPdg", + pair, + 66, + |p, qs| { + p.iswapdg(qs); + }, + |p, qs| { + p.iswapdg(qs); + }, + ); + } + } + + #[test] + fn word_boundary_propagation_matches_sparse_pauli_prop() { + let qubits = [63, 64, 65]; + let qids = qubits.map(QubitId); + let mut sparse = PauliProp::with_sign_tracking(66); + let mut bitmask = BitmaskPauliProp::with_num_qubits(66); + + sparse.track_z(&qubits); + bitmask.track_z(&qubits); + sparse.h(&qids); + bitmask.h(&qids); + + assert_eq!(bitmask.dense_string(), sparse.dense_string()); + + let sparse_meas = sparse.mz(&qids); + let bitmask_meas = bitmask.mz(&qids); + assert_eq!( + bitmask_meas.iter().map(|m| m.outcome).collect::>(), + sparse_meas.iter().map(|m| m.outcome).collect::>() + ); + assert_eq!( + bitmask_meas + .iter() + .map(|m| m.is_deterministic) + .collect::>(), + sparse_meas + .iter() + .map(|m| m.is_deterministic) + .collect::>() + ); + } + + #[test] + fn sequential_gate_composition_matches_sparse_pauli_prop() { + let sequence = |sparse: &mut PauliProp, bitmask: &mut BitmaskPauliProp| { + sparse + .h(&[QubitId(0)]) + .sz(&[QubitId(1)]) + .cx(&[(QubitId(0), QubitId(1))]) + .sxx(&[(QubitId(1), QubitId(2))]) + .iswap(&[(QubitId(0), QubitId(2))]) + .sydg(&[QubitId(2)]) + .cz(&[(QubitId(2), QubitId(1))]) + .swap(&[(QubitId(0), QubitId(1))]); + bitmask + .h(&[QubitId(0)]) + .sz(&[QubitId(1)]) + .cx(&[(QubitId(0), QubitId(1))]) + .sxx(&[(QubitId(1), QubitId(2))]) + .iswap(&[(QubitId(0), QubitId(2))]) + .sydg(&[QubitId(2)]) + .cz(&[(QubitId(2), QubitId(1))]) + .swap(&[(QubitId(0), QubitId(1))]); + }; + + for input in all_paulis(3) { + let mut sparse = sparse_prop_from_dense(&input); + let mut bitmask = bitmask_prop_from_dense(&input); + sequence(&mut sparse, &mut bitmask); + assert_eq!( + bitmask.dense_string(), + sparse.dense_string(), + "sequential composition: {input}" + ); + } + } + + #[test] + fn measurement_reset_and_identity_match_fault_catalog_semantics() { + let mut prop = BitmaskPauliProp::with_num_qubits(3); + prop.track_y(&[1]); + + let meas = prop.mz(&[QubitId(0), QubitId(1), QubitId(2)]); + assert_eq!( + meas.iter().map(|m| m.outcome).collect::>(), + vec![false, true, false] + ); + assert!(meas.iter().all(|m| m.is_deterministic)); + + prop.clear_qubit(1); + assert!(prop.is_identity()); + + prop.track_z(&[2]); + assert_eq!(prop.weight(), 1); + prop.reset(); + assert!(prop.is_identity()); + } +} diff --git a/crates/pecos-simulators/src/circuit_executor.rs b/crates/pecos-simulators/src/circuit_executor.rs index 219f9178a..18261f0d1 100644 --- a/crates/pecos-simulators/src/circuit_executor.rs +++ b/crates/pecos-simulators/src/circuit_executor.rs @@ -12,9 +12,10 @@ //! Batched circuit execution for Clifford simulators. //! -//! This module provides efficient circuit execution using the batched gate groups -//! from `TickCircuitSoA`. Instead of dispatching each gate individually, gates of -//! the same type are applied as a single batch call. +//! This module provides efficient circuit execution using the full-fidelity +//! batched gate commands stored by `TickCircuit`. Instead of dispatching each +//! individual gate application, stored batched commands are applied as one +//! simulator call. //! //! # Performance Benefits //! @@ -26,15 +27,13 @@ //! //! ``` //! use pecos_simulators::{SparseStab, CircuitExecutor}; -//! use pecos_quantum::TickCircuitSoA; +//! use pecos_quantum::TickCircuit; //! -//! let mut builder = TickCircuitSoA::builder(); -//! builder -//! .tick().pz(&[0, 1, 2, 3]) -//! .tick().h(&[0, 1, 2, 3]) -//! .tick().cx(&[(0, 1), (2, 3)]) -//! .tick().mz(&[0, 1, 2, 3]); -//! let circuit = builder.build(); +//! let mut circuit = TickCircuit::new(); +//! circuit.tick().pz(&[0, 1, 2, 3]); +//! circuit.tick().h(&[0, 1, 2, 3]); +//! circuit.tick().cx(&[(0, 1), (2, 3)]); +//! circuit.tick().mz(&[0, 1, 2, 3]); //! //! let mut sim = SparseStab::new(4); //! let executor = CircuitExecutor::new(&circuit); @@ -42,9 +41,9 @@ //! ``` use crate::{CliffordGateable, MeasurementResult}; -use pecos_core::QubitId; use pecos_core::gate_type::GateType; -use pecos_quantum::{GateBatch, TickBatches, TickCircuitSoA, TickGateGroups}; +use pecos_core::{Gate, QubitId}; +use pecos_quantum::TickCircuit; use smallvec::SmallVec; /// Convert a flat qubit slice `[c0, t0, c1, t1, ...]` to a vec of pairs. @@ -55,20 +54,20 @@ fn flat_to_pairs(qubits: &[QubitId]) -> SmallVec<[(QubitId, QubitId); 4]> { .collect() } -/// Executes a `TickCircuitSoA` on a Clifford simulator using batched operations. +/// Executes a `TickCircuit` on a Clifford simulator using batched operations. /// -/// This executor leverages the pre-grouped gate batches in `TickCircuitSoA` for -/// efficient execution with minimal dispatch overhead. +/// This executor leverages the full-fidelity batched gate commands in +/// `TickCircuit` for efficient execution with minimal dispatch overhead. pub struct CircuitExecutor<'a> { /// The circuit to execute. - circuit: &'a TickCircuitSoA, + circuit: &'a TickCircuit, } impl<'a> CircuitExecutor<'a> { /// Creates a new executor for the given circuit. #[inline] #[must_use] - pub fn new(circuit: &'a TickCircuitSoA) -> Self { + pub fn new(circuit: &'a TickCircuit) -> Self { Self { circuit } } @@ -78,66 +77,38 @@ impl<'a> CircuitExecutor<'a> { pub fn run(&self, sim: &mut S) -> Vec { let mut measurements = Vec::new(); - for (_tick_idx, tick) in self.circuit.iter_ticks_batched() { - Self::execute_tick(sim, tick, &mut measurements); + for (_tick_idx, tick) in self.circuit.iter_ticks() { + for batch in tick.iter_gate_batches() { + Self::execute_gate_batch(sim, batch.as_gate(), &mut measurements); + } } measurements } - /// Runs a single tick on the simulator. - #[inline] - fn execute_tick( - sim: &mut S, - tick: &TickBatches, - measurements: &mut Vec, - ) { - for batch in tick.iter() { - Self::execute_batch(sim, batch, measurements); - } - } - - /// Executes a single batch of gates. + /// Executes a single full-fidelity batched gate command. /// /// This is the core dispatch function - one match per batch, not per gate. #[inline] - fn execute_batch( + fn execute_gate_batch( sim: &mut S, - batch: &GateBatch, + batch: &Gate, measurements: &mut Vec, ) { - execute_single_batch(sim, batch, measurements); + execute_gate_command(sim, batch, measurements); } } -/// Executes a `TickGateGroups` directly on a simulator. -/// -/// This is a simpler interface when you have gate groups but not the full circuit. -pub fn execute_batched( - groups: &TickGateGroups, - sim: &mut S, -) -> Vec { - let mut measurements = Vec::new(); - - for (_tick_idx, tick) in groups.iter_ticks() { - for batch in tick.iter() { - execute_single_batch(sim, batch, &mut measurements); - } - } - - measurements -} - -/// Executes a single batch on a simulator. +/// Executes one full-fidelity `TickCircuit` gate command on a simulator. #[inline] -fn execute_single_batch( +fn execute_gate_command( sim: &mut S, - batch: &GateBatch, + gate: &Gate, measurements: &mut Vec, ) { - let qubits = batch.qubits(); + let qubits = gate.qubits.as_slice(); - match batch.gate_type { + match gate.gate_type { GateType::I => { sim.identity(qubits); } @@ -287,22 +258,15 @@ impl GateSystemRegistry { mod tests { use super::*; use crate::SparseStab; - use pecos_quantum::TickCircuitSoA; + use pecos_quantum::TickCircuit; #[test] fn test_circuit_executor_basic() { - let mut builder = TickCircuitSoA::builder(); - builder - .tick() - .pz(&[0, 1]) - .tick() - .h(&[0]) - .tick() - .cx(&[(0, 1)]) - .tick() - .mz(&[0, 1]); - - let circuit = builder.build(); + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0, 1]); + circuit.tick().h(&[0]); + circuit.tick().cx(&[(0, 1)]); + circuit.tick().mz(&[0, 1]); let mut sim = SparseStab::new(2); let executor = CircuitExecutor::new(&circuit); @@ -315,18 +279,11 @@ mod tests { #[test] fn test_circuit_executor_batched_gates() { // Create a circuit with multiple gates of same type per tick - let mut builder = TickCircuitSoA::builder(); - builder - .tick() - .pz(&[0, 1, 2, 3]) // 4 preps in one batch - .tick() - .h(&[0, 1, 2, 3]) // 4 H gates in one batch - .tick() - .cx(&[(0, 1), (2, 3)]) // 2 CX gates in one batch - .tick() - .mz(&[0, 1, 2, 3]); // 4 measurements in one batch - - let circuit = builder.build(); + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0, 1, 2, 3]); // 4 preps in one batch + circuit.tick().h(&[0, 1, 2, 3]); // 4 H gates in one batch + circuit.tick().cx(&[(0, 1), (2, 3)]); // 2 CX gates in one batch + circuit.tick().mz(&[0, 1, 2, 3]); // 4 measurements in one batch let mut sim = SparseStab::new(4); let executor = CircuitExecutor::new(&circuit); @@ -335,23 +292,4 @@ mod tests { // Should have 4 measurements assert_eq!(measurements.len(), 4); } - - #[test] - fn test_execute_batched_function() { - let mut builder = TickCircuitSoA::builder(); - builder - .tick() - .pz(&[0, 1]) - .tick() - .h(&[0, 1]) - .tick() - .mz(&[0, 1]); - - let circuit = builder.build(); - - let mut sim = SparseStab::new(2); - let measurements = execute_batched(&circuit.batched, &mut sim); - - assert_eq!(measurements.len(), 2); - } } diff --git a/crates/pecos-simulators/src/clifford_matrix_oracle.rs b/crates/pecos-simulators/src/clifford_matrix_oracle.rs new file mode 100644 index 000000000..b008b98f2 --- /dev/null +++ b/crates/pecos-simulators/src/clifford_matrix_oracle.rs @@ -0,0 +1,316 @@ +// Copyright 2026 The PECOS Developers +// Licensed under the Apache License, Version 2.0 + +use num_complex::Complex64; +use std::f64::consts::FRAC_1_SQRT_2; + +const MATRIX_EPS: f64 = 1e-9; + +#[derive(Clone, Copy, Debug)] +#[allow(clippy::upper_case_acronyms)] +pub(crate) enum CliffordMatrixGate { + SZdg, + F, + Fdg, + SX, + SXdg, + SY, + SYdg, + CX, + CY, + CZ, + SXX, + SXXdg, + SYY, + SYYdg, + SZZ, + SZZdg, + SWAP, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) struct SignedPauli { + pub(crate) sign: i8, + pub(crate) pauli: String, +} + +#[derive(Clone, Debug)] +struct Matrix { + n: usize, + data: Vec, +} + +impl Matrix { + fn from_data(n: usize, data: Vec) -> Self { + assert_eq!(data.len(), n * n); + Self { n, data } + } + + fn zeros(n: usize) -> Self { + Self { + n, + data: vec![Complex64::new(0.0, 0.0); n * n], + } + } + + fn identity(n: usize) -> Self { + let mut matrix = Self::zeros(n); + for i in 0..n { + matrix.set(i, i, Complex64::new(1.0, 0.0)); + } + matrix + } + + fn get(&self, row: usize, col: usize) -> Complex64 { + self.data[row * self.n + col] + } + + fn set(&mut self, row: usize, col: usize, value: Complex64) { + self.data[row * self.n + col] = value; + } + + fn add(&self, other: &Self) -> Self { + assert_eq!(self.n, other.n); + let data = self + .data + .iter() + .zip(other.data.iter()) + .map(|(a, b)| a + b) + .collect(); + Self::from_data(self.n, data) + } + + fn scale(&self, scalar: Complex64) -> Self { + let data = self.data.iter().map(|v| scalar * v).collect(); + Self::from_data(self.n, data) + } + + fn mul(&self, other: &Self) -> Self { + assert_eq!(self.n, other.n); + let mut out = Self::zeros(self.n); + for row in 0..self.n { + for col in 0..self.n { + let mut value = Complex64::new(0.0, 0.0); + for k in 0..self.n { + value += self.get(row, k) * other.get(k, col); + } + out.set(row, col, value); + } + } + out + } + + fn dagger(&self) -> Self { + let mut out = Self::zeros(self.n); + for row in 0..self.n { + for col in 0..self.n { + out.set(col, row, self.get(row, col).conj()); + } + } + out + } + + fn approx_eq(&self, other: &Self) -> bool { + assert_eq!(self.n, other.n); + self.data + .iter() + .zip(other.data.iter()) + .all(|(a, b)| (*a - *b).norm() < MATRIX_EPS) + } +} + +pub(crate) fn all_pauli_strings(num_qubits: usize) -> Vec { + let mut strings = vec![String::new()]; + for _ in 0..num_qubits { + let mut next = Vec::with_capacity(strings.len() * 4); + for prefix in &strings { + for suffix in ['I', 'X', 'Y', 'Z'] { + let mut value = prefix.clone(); + value.push(suffix); + next.push(value); + } + } + strings = next; + } + strings +} + +pub(crate) fn conjugate_pauli(gate: CliffordMatrixGate, input: &str) -> SignedPauli { + let unitary = gate_matrix(gate); + let input_matrix = pauli_string_matrix(input); + let image = unitary.mul(&input_matrix).mul(&unitary.dagger()); + classify_signed_pauli(&image, input.len()) +} + +fn classify_signed_pauli(image: &Matrix, num_qubits: usize) -> SignedPauli { + for pauli in all_pauli_strings(num_qubits) { + let matrix = pauli_string_matrix(&pauli); + if image.approx_eq(&matrix) { + return SignedPauli { sign: 1, pauli }; + } + if image.approx_eq(&matrix.scale(Complex64::new(-1.0, 0.0))) { + return SignedPauli { sign: -1, pauli }; + } + } + panic!("matrix image is not a signed Pauli"); +} + +fn gate_matrix(gate: CliffordMatrixGate) -> Matrix { + match gate { + CliffordMatrixGate::SZdg => sqrt_pauli_matrix("Z", true), + CliffordMatrixGate::F => sqrt_pauli_matrix("Z", false).mul(&sqrt_pauli_matrix("X", false)), + CliffordMatrixGate::Fdg => sqrt_pauli_matrix("X", true).mul(&sqrt_pauli_matrix("Z", true)), + CliffordMatrixGate::SX => sqrt_pauli_matrix("X", false), + CliffordMatrixGate::SXdg => sqrt_pauli_matrix("X", true), + CliffordMatrixGate::SY => sqrt_pauli_matrix("Y", false), + CliffordMatrixGate::SYdg => sqrt_pauli_matrix("Y", true), + CliffordMatrixGate::CX => controlled_x_matrix(), + CliffordMatrixGate::CY => controlled_y_matrix(), + CliffordMatrixGate::CZ => controlled_z_matrix(), + CliffordMatrixGate::SXX => sqrt_pauli_matrix("XX", false), + CliffordMatrixGate::SXXdg => sqrt_pauli_matrix("XX", true), + CliffordMatrixGate::SYY => sqrt_pauli_matrix("YY", false), + CliffordMatrixGate::SYYdg => sqrt_pauli_matrix("YY", true), + CliffordMatrixGate::SZZ => sqrt_pauli_matrix("ZZ", false), + CliffordMatrixGate::SZZdg => sqrt_pauli_matrix("ZZ", true), + CliffordMatrixGate::SWAP => swap_matrix(), + } +} + +fn sqrt_pauli_matrix(pauli: &str, adjoint: bool) -> Matrix { + let pauli = pauli_string_matrix(pauli); + let identity = Matrix::identity(pauli.n); + let phase_sign = if adjoint { 1.0 } else { -1.0 }; + identity + .scale(Complex64::new(FRAC_1_SQRT_2, 0.0)) + .add(&pauli.scale(Complex64::new(0.0, phase_sign * FRAC_1_SQRT_2))) +} + +fn pauli_string_matrix(pauli: &str) -> Matrix { + let mut matrix = Matrix::from_data(1, vec![Complex64::new(1.0, 0.0)]); + for label in pauli.chars() { + matrix = kron(&matrix, &single_pauli_matrix(label)); + } + matrix +} + +fn single_pauli_matrix(label: char) -> Matrix { + let one = Complex64::new(1.0, 0.0); + let minus_one = Complex64::new(-1.0, 0.0); + let zero = Complex64::new(0.0, 0.0); + let i = Complex64::new(0.0, 1.0); + let minus_i = Complex64::new(0.0, -1.0); + + match label { + 'I' => Matrix::from_data(2, vec![one, zero, zero, one]), + 'X' => Matrix::from_data(2, vec![zero, one, one, zero]), + 'Y' => Matrix::from_data(2, vec![zero, minus_i, i, zero]), + 'Z' => Matrix::from_data(2, vec![one, zero, zero, minus_one]), + _ => panic!("invalid Pauli label {label}"), + } +} + +fn kron(left: &Matrix, right: &Matrix) -> Matrix { + let n = left.n * right.n; + let mut out = Matrix::zeros(n); + for lr in 0..left.n { + for lc in 0..left.n { + for rr in 0..right.n { + for rc in 0..right.n { + out.set( + lr * right.n + rr, + lc * right.n + rc, + left.get(lr, lc) * right.get(rr, rc), + ); + } + } + } + } + out +} + +fn controlled_y_matrix() -> Matrix { + let one = Complex64::new(1.0, 0.0); + let zero = Complex64::new(0.0, 0.0); + let i = Complex64::new(0.0, 1.0); + let minus_i = Complex64::new(0.0, -1.0); + Matrix::from_data( + 4, + vec![ + one, zero, zero, zero, zero, one, zero, zero, zero, zero, zero, minus_i, zero, zero, i, + zero, + ], + ) +} + +fn controlled_x_matrix() -> Matrix { + Matrix::from_data( + 4, + vec![ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + ], + ) +} + +fn controlled_z_matrix() -> Matrix { + Matrix::from_data( + 4, + vec![ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(-1.0, 0.0), + ], + ) +} + +fn swap_matrix() -> Matrix { + Matrix::from_data( + 4, + vec![ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(1.0, 0.0), + ], + ) +} diff --git a/crates/pecos-simulators/src/dense_stab.rs b/crates/pecos-simulators/src/dense_stab.rs index fc77da9fd..f0f95559a 100644 --- a/crates/pecos-simulators/src/dense_stab.rs +++ b/crates/pecos-simulators/src/dense_stab.rs @@ -51,7 +51,7 @@ use crate::{CliffordGateable, MeasurementResult, QuantumSimulator, StabilizerTableauSimulator}; use core::fmt::Debug; -use pecos_core::{QubitId, RngManageable}; +use pecos_core::{Pauli, PauliString, Phase, QuarterPhase, QubitId, RngManageable}; use pecos_random::rng_ext::RngProbabilityExt; use pecos_random::{PecosRng, Rng, SeedableRng}; @@ -1348,6 +1348,100 @@ impl StabilizerTableauSimulator for DenseS } impl DenseStab { + fn generator_from_rows( + num_qubits: usize, + words_per_row: usize, + row_x: &[u64], + row_z: &[u64], + signs_minus: &[u64], + signs_i: &[u64], + row: usize, + ) -> PauliString { + let mut phase = match (get_sign(signs_minus, row), get_sign(signs_i, row)) { + (false, false) => QuarterPhase::PlusOne, + (true, false) => QuarterPhase::MinusOne, + (false, true) => QuarterPhase::PlusI, + (true, true) => QuarterPhase::MinusI, + }; + let base = row * words_per_row; + let mut paulis = Vec::new(); + let mut num_y_terms = 0usize; + for qubit in 0..num_qubits { + let word_idx = base + qubit / 64; + let bit_mask = 1u64 << (qubit % 64); + let in_x = row_x[word_idx] & bit_mask != 0; + let in_z = row_z[word_idx] & bit_mask != 0; + let pauli = match (in_x, in_z) { + (false, false) => continue, + (true, false) => Pauli::X, + (false, true) => Pauli::Z, + (true, true) => { + num_y_terms += 1; + Pauli::Y + } + }; + paulis.push((pauli, QubitId::new(qubit))); + } + for _ in 0..num_y_terms { + phase = phase.multiply(&QuarterPhase::MinusI); + } + PauliString::with_phase_and_paulis(phase, paulis) + } + + fn generators_from_rows( + &self, + row_x: &[u64], + row_z: &[u64], + signs_minus: &[u64], + signs_i: &[u64], + ) -> Vec { + (0..self.num_qubits) + .map(|row| { + Self::generator_from_rows( + self.num_qubits, + self.words_per_row, + row_x, + row_z, + signs_minus, + signs_i, + row, + ) + }) + .collect() + } + + /// Extracts the stabilizer generators as a [`PauliStabilizerGroup`]. + /// + /// The dense tableau stores X/Z support plus two sign bits per row. This + /// converts that signed row representation into the shared algebraic + /// stabilizer type used by state inspection and quantum-info routines. + /// + /// [`PauliStabilizerGroup`]: pecos_quantum::PauliStabilizerGroup + #[must_use] + pub fn to_stabilizer_group(&self) -> pecos_quantum::PauliStabilizerGroup { + let generators = self.generators_from_rows( + &self.stab_row_x, + &self.stab_row_z, + &self.stab_signs_minus, + &self.stab_signs_i, + ); + pecos_quantum::PauliStabilizerGroup::from_generators_unchecked(generators) + } + + /// Extracts the destabilizer generators as a [`PauliSequence`]. + /// + /// [`PauliSequence`]: pecos_quantum::PauliSequence + #[must_use] + pub fn to_destabilizer_sequence(&self) -> pecos_quantum::PauliSequence { + let generators = self.generators_from_rows( + &self.destab_row_x, + &self.destab_row_z, + &self.destab_signs_minus, + &self.destab_signs_i, + ); + pecos_quantum::PauliSequence::new(generators) + } + /// Produces a tableau string from dense bit arrays. fn gen_tableau_string( num_qubits: usize, diff --git a/crates/pecos-simulators/src/density_matrix.rs b/crates/pecos-simulators/src/density_matrix.rs index dce74e0d2..03aca37db 100644 --- a/crates/pecos-simulators/src/density_matrix.rs +++ b/crates/pecos-simulators/src/density_matrix.rs @@ -14,11 +14,37 @@ use super::arbitrary_rotation_gateable::ArbitraryRotationGateable; use super::clifford_gateable::{CliffordGateable, MeasurementResult}; use super::quantum_simulator::QuantumSimulator; use super::state_vec::StateVec; -use pecos_core::{Angle64, QubitId, RngManageable}; +use super::state_vec_soa::StateVecSoA; +use nalgebra::DMatrix; +use pecos_core::{Angle64, ChannelExpr, QubitId, RngManageable}; +use pecos_quantum::{ChannelError, KrausOps}; use pecos_random::{PecosRng, Rng, RngExt, SeedableRng}; use core::fmt::{Debug, Display, Formatter, Write}; use num_complex::Complex64; +use std::error::Error; + +const PURE_STATE_TOLERANCE: f64 = 1e-10; + +/// Error returned when converting between simulator state representations. +#[derive(Clone, Debug, PartialEq)] +pub enum StateConversionError { + /// The density matrix is not rank-1 within the conversion tolerance. + MixedDensityMatrix { residual: f64 }, +} + +impl Display for StateConversionError { + fn fmt(&self, f: &mut Formatter<'_>) -> core::fmt::Result { + match self { + Self::MixedDensityMatrix { residual } => write!( + f, + "density matrix is not a pure state; reconstruction residual is {residual}" + ), + } + } +} + +impl Error for StateConversionError {} /// A quantum state simulator using the density matrix representation via the Choi-Jamiolkowski isomorphism /// @@ -836,6 +862,143 @@ where self } + + /// Apply a symbolic channel expression to this density matrix. + /// + /// Supported expressions are those convertible to same-Hilbert-space Kraus + /// operators: unitary, mixed-unitary, amplitude damping, phase damping, + /// tensor, and composition. Erasure, leakage, and gate instruments are + /// intentionally rejected because they need extra flag/outcome semantics. + /// + /// # Errors + /// + /// Returns an error if the channel expression is unsupported or invalid for + /// this simulator's qubit count. + pub fn apply_channel_expr(&mut self, channel: &ChannelExpr) -> Result<&mut Self, ChannelError> { + let kraus = KrausOps::from_channel_expr_with_num_qubits(channel, self.num_physical_qubits)?; + self.apply_kraus_ops(&kraus) + } + + /// Apply Kraus operators to this density matrix. + /// + /// # Errors + /// + /// Returns an error if the Kraus operators are not defined on the same + /// number of qubits as this density matrix. + pub fn apply_kraus_ops(&mut self, kraus: &KrausOps) -> Result<&mut Self, ChannelError> { + let num_qubits_u32 = u32::try_from(self.num_physical_qubits).map_err(|_| { + ChannelError::DimensionOverflow { + num_qubits: self.num_physical_qubits, + } + })?; + let dim = 1usize + .checked_shl(num_qubits_u32) + .ok_or(ChannelError::DimensionOverflow { + num_qubits: self.num_physical_qubits, + })?; + if kraus.num_qubits() != self.num_physical_qubits { + let actual_qubits_u32 = + u32::try_from(kraus.num_qubits()).map_err(|_| ChannelError::DimensionOverflow { + num_qubits: kraus.num_qubits(), + })?; + let actual_dim = + 1usize + .checked_shl(actual_qubits_u32) + .ok_or(ChannelError::DimensionOverflow { + num_qubits: kraus.num_qubits(), + })?; + return Err(ChannelError::InvalidMatrixShape { + expected_rows: dim, + expected_cols: dim, + rows: actual_dim, + cols: actual_dim, + }); + } + + let rho = self.get_density_matrix(); + let flat: Vec = rho.iter().flat_map(|row| row.iter().copied()).collect(); + let rho_matrix = DMatrix::from_row_slice(dim, dim, &flat); + let mut evolved = DMatrix::zeros(dim, dim); + for operator in kraus.operators() { + evolved += operator * &rho_matrix * operator.adjoint(); + } + + let new_rho: Vec> = (0..dim) + .map(|row| (0..dim).map(|col| evolved[(row, col)]).collect()) + .collect(); + self.set_from_density_matrix(&new_rho); + Ok(self) + } +} + +impl From<&StateVecSoA> for DensityMatrix +where + R: Rng + SeedableRng + Debug + Clone, +{ + fn from(state: &StateVecSoA) -> Self { + let mut state = state.clone(); + let amplitudes = state.state(); + let dim = amplitudes.len(); + let num_physical_qubits = dim.trailing_zeros() as usize; + let mut purification = vec![Complex64::new(0.0, 0.0); dim * dim]; + + for (row, amplitude) in amplitudes.iter().enumerate() { + purification[row << num_physical_qubits] = *amplitude; + } + + Self { + num_physical_qubits, + state_vector: StateVec::from_state(&purification, state.rng().clone()), + } + } +} + +impl TryFrom<&DensityMatrix> for Vec +where + R: Rng + SeedableRng + Debug + Clone, +{ + type Error = StateConversionError; + + fn try_from(density_matrix: &DensityMatrix) -> Result { + let mut density_matrix = density_matrix.clone(); + let rho = density_matrix.get_density_matrix(); + pure_state_from_density_matrix(&rho) + } +} + +fn pure_state_from_density_matrix( + rho: &[Vec], +) -> Result, StateConversionError> { + let dim = rho.len(); + let (pivot, pivot_probability) = rho + .iter() + .enumerate() + .map(|(i, row)| (i, row[i].re.max(0.0))) + .max_by(|(_, left), (_, right)| left.total_cmp(right)) + .unwrap_or((0, 0.0)); + + if pivot_probability <= PURE_STATE_TOLERANCE { + return Err(StateConversionError::MixedDensityMatrix { residual: 1.0 }); + } + + let pivot_amplitude = pivot_probability.sqrt(); + let state: Vec = (0..dim) + .map(|row| rho[row][pivot] / pivot_amplitude) + .collect(); + + let mut residual = 0.0_f64; + for row in 0..dim { + for col in 0..dim { + let reconstructed = state[row] * state[col].conj(); + residual = residual.max((rho[row][col] - reconstructed).norm()); + } + } + + if residual > PURE_STATE_TOLERANCE { + return Err(StateConversionError::MixedDensityMatrix { residual }); + } + + Ok(state) } impl Display for DensityMatrix @@ -1322,5 +1485,91 @@ mod tests { assert!(dm.is_pure()); } + #[test] + fn channel_expr_bit_flip_applies_to_density_matrix() { + let mut dm = DensityMatrix::new(1); + dm.apply_channel_expr(&pecos_core::channel::BitFlip(1.0, 0)) + .unwrap(); + + assert!(dm.probability(0) < 1e-10); + assert!((dm.probability(1) - 1.0).abs() < 1e-10); + } + + #[test] + fn channel_expr_embeds_local_channel_in_larger_density_matrix() { + let mut dm = DensityMatrix::new(2); + dm.apply_channel_expr(&pecos_core::channel::BitFlip(1.0, 1)) + .unwrap(); + + assert!(dm.probability(0) < 1e-10); + assert!((dm.probability(2) - 1.0).abs() < 1e-10); + } + + #[test] + fn tensor_channel_expr_applies_to_noncontiguous_density_matrix_qubits() { + let mut dm = DensityMatrix::new(3); + let channel = ChannelExpr::Tensor(vec![ + pecos_core::channel::BitFlip(1.0, 0), + pecos_core::channel::BitFlip(1.0, 2), + ]); + + dm.apply_channel_expr(&channel).unwrap(); + + assert!(dm.probability(0) < 1e-10); + assert!((dm.probability(5) - 1.0).abs() < 1e-10); + } + + #[test] + fn out_of_range_channel_expr_is_rejected_without_mutating_state() { + let mut dm = DensityMatrix::new(2); + dm.h(&qid(0)).cx(&[(QubitId(0), QubitId(1))]); + let before = dm.get_flattened_density_matrix(); + + let err = dm + .apply_channel_expr(&pecos_core::channel::BitFlip(0.5, 2)) + .expect_err("channel should not apply outside the simulator range"); + + assert!(matches!( + err, + ChannelError::QubitOutOfRange { + num_qubits: 2, + qubit: 2 + } + )); + let after = dm.get_flattened_density_matrix(); + assert_eq!(after, before); + } + + #[test] + fn state_vector_converts_to_density_matrix_and_back() { + let mut state = StateVecSoA::new(2); + state.h(&qid(0)).cx(&[(QubitId(0), QubitId(1))]); + + let mut density_matrix = DensityMatrix::from(&state); + assert!((density_matrix.probability(0) - 0.5).abs() < 1e-10); + assert!(density_matrix.probability(1) < 1e-10); + assert!(density_matrix.probability(2) < 1e-10); + assert!((density_matrix.probability(3) - 0.5).abs() < 1e-10); + + let recovered = Vec::::try_from(&density_matrix).unwrap(); + let expected = state.state(); + + for (actual, expected) in recovered.iter().zip(expected.iter()) { + assert!((*actual - *expected).norm() < 1e-10); + } + } + + #[test] + fn mixed_density_matrix_rejects_state_vector_conversion() { + let mut density_matrix = DensityMatrix::new(1); + density_matrix.prepare_maximally_mixed(); + + let err = Vec::::try_from(&density_matrix).unwrap_err(); + assert!(matches!( + err, + StateConversionError::MixedDensityMatrix { .. } + )); + } + // Additional tests for other gates and operations would be added here } diff --git a/crates/pecos-simulators/src/gens.rs b/crates/pecos-simulators/src/gens.rs index a53ffbe86..46808a15b 100644 --- a/crates/pecos-simulators/src/gens.rs +++ b/crates/pecos-simulators/src/gens.rs @@ -164,8 +164,9 @@ impl GensHybrid { #[must_use] pub fn generator(&self, i: usize) -> PauliString { assert!(i < self.num_generators(), "generator index out of bounds"); - let phase = self.generator_phase(i); + let mut phase = self.generator_phase(i); let mut paulis = Vec::new(); + let mut num_y_terms = 0usize; for q in 0..self.num_qubits { let has_x = self.row_x[i].contains(q); let has_z = self.row_z[i].contains(q); @@ -173,10 +174,16 @@ impl GensHybrid { (false, false) => continue, (true, false) => Pauli::X, (false, true) => Pauli::Z, - (true, true) => Pauli::Y, + (true, true) => { + num_y_terms += 1; + Pauli::Y + } }; paulis.push((pauli, QubitId::new(q))); } + for _ in 0..num_y_terms { + phase = phase.multiply(&QuarterPhase::MinusI); + } PauliString::with_phase_and_paulis(phase, paulis) } @@ -391,10 +398,11 @@ impl GensGeneric { pub fn generator(&self, i: usize) -> PauliString { assert!(i < self.num_generators(), "generator index out of bounds"); - let phase = self.generator_phase(i); + let mut phase = self.generator_phase(i); // Collect non-identity Paulis let mut paulis = Vec::new(); + let mut num_y_terms = 0usize; // Iterate over all qubits and determine the Pauli at each position for q in 0..self.num_qubits { @@ -405,11 +413,17 @@ impl GensGeneric { (false, false) => continue, // Identity, skip (true, false) => Pauli::X, (false, true) => Pauli::Z, - (true, true) => Pauli::Y, + (true, true) => { + num_y_terms += 1; + Pauli::Y + } }; paulis.push((pauli, QubitId::new(q))); } + for _ in 0..num_y_terms { + phase = phase.multiply(&QuarterPhase::MinusI); + } PauliString::with_phase_and_paulis(phase, paulis) } diff --git a/crates/pecos-simulators/src/lib.rs b/crates/pecos-simulators/src/lib.rs index a67d3c4cc..88791d4bd 100644 --- a/crates/pecos-simulators/src/lib.rs +++ b/crates/pecos-simulators/src/lib.rs @@ -12,9 +12,13 @@ pub mod arbitrary_rotation_gateable; pub mod batched_ops; +#[doc(hidden)] +pub mod bitmask_pauli_prop; pub mod circuit_executor; pub mod clifford_frame; pub mod clifford_gateable; +#[cfg(test)] +mod clifford_matrix_oracle; pub mod clifford_rotation; pub mod clifford_test_utils; pub mod coin_toss; @@ -42,6 +46,7 @@ pub mod sparse_stab_y; pub mod stabilizer; pub mod stabilizer_tableau; pub mod stabilizer_test_utils; +pub mod state_access; pub mod state_vec; pub mod state_vec_aos; pub mod state_vec_soa; @@ -55,7 +60,9 @@ pub mod symbolic_sparse_stab_bitset; pub use arbitrary_rotation_gateable::ArbitraryRotationGateable; pub use batched_ops::{BatchedOps, CommandBuffer, RawOps}; -pub use circuit_executor::{CircuitExecutor, GateSystem, GateSystemRegistry, execute_batched}; +#[doc(hidden)] +pub use bitmask_pauli_prop::BitmaskPauliProp; +pub use circuit_executor::{CircuitExecutor, GateSystem, GateSystemRegistry}; pub use clifford_gateable::{CliffordGateable, MeasurementResult}; pub use coin_toss::CoinToss; /// Sparse index representation of stabilizer/destabilizer generators. @@ -100,9 +107,12 @@ pub use sparse_stab_y::{ }; pub use stabilizer::Stabilizer; pub use stabilizer_tableau::StabilizerTableauSimulator; +pub use state_access::{ + DensityMatrixAccess, PauliExpectationAccess, StabilizerStateVectorConversion, StateAccessError, + StateInfo, StateSampling, StateVectorAccess, random_statevector, +}; // StateVec uses the sparse SoA implementation optimized for QEC workloads. // The dense implementation is available as DenseStateVec / StateVecSoA. -pub use state_vec::StateVec as StateVecOld; pub use state_vec_aos::StateVecAoS; pub use state_vec_soa::StateVecSoA as DenseStateVec; pub use state_vec_soa::StateVecSoA; diff --git a/crates/pecos-simulators/src/measurement_stress_test_utils.rs b/crates/pecos-simulators/src/measurement_stress_test_utils.rs index 787c09a0d..336128405 100644 --- a/crates/pecos-simulators/src/measurement_stress_test_utils.rs +++ b/crates/pecos-simulators/src/measurement_stress_test_utils.rs @@ -268,8 +268,9 @@ pub fn run_measurement_stress_tests(sim: &mut S) { /// Generate a test that runs the measurement stress suite on a simulator type. /// /// Usage: -/// ```ignore -/// use pecos_simulators::measurement_stress_test_suite; +/// ``` +/// use pecos_simulators::{StabVec, measurement_stress_test_suite}; +/// /// measurement_stress_test_suite!(StabVec, 4); /// ``` #[macro_export] diff --git a/crates/pecos-simulators/src/pauli_prop.rs b/crates/pecos-simulators/src/pauli_prop.rs index 5927b65db..0a24fef5f 100644 --- a/crates/pecos-simulators/src/pauli_prop.rs +++ b/crates/pecos-simulators/src/pauli_prop.rs @@ -372,6 +372,15 @@ impl PauliProp { count } + /// Remove all Pauli operators from a specific qubit. + /// + /// Models reset (PZ) which absorbs any propagating error on that qubit. + pub fn clear_qubit(&mut self, qubit: usize) { + use pecos_core::sets::set::Set; + self.xs.remove(&qubit); + self.zs.remove(&qubit); + } + /// Checks if this is the identity operator (no Pauli operators on any qubit). /// /// # Returns @@ -537,6 +546,23 @@ impl PauliProp { pub fn to_dense_string(&self) -> String { format!("{}{}", self.sign_string(), self.dense_string()) } + + fn set_x_component(&mut self, q: usize, value: bool) { + if self.contains_x(q) != value { + self.track_x(&[q]); + } + } + + fn set_z_component(&mut self, q: usize, value: bool) { + if self.contains_z(q) != value { + self.track_z(&[q]); + } + } + + fn set_components(&mut self, q: usize, x: bool, z: bool) { + self.set_x_component(q, x); + self.set_z_component(q, z); + } } impl fmt::Display for PauliProp { @@ -573,6 +599,21 @@ impl CliffordGateable for PauliProp { self } + /// Applies the adjoint square root of Z gate. + /// + /// Ignoring global phase, `SZ` and `SZdg` have the same binary Pauli action: + /// X <-> Y, Z -> Z. + #[inline] + fn szdg(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + let qu = q.index(); + if self.contains_x(qu) { + self.track_z(&[qu]); + } + } + self + } + /// Applies the Hadamard (H) gate to the specified qubits. /// /// The H gate transforms Pauli operators as follows: @@ -611,6 +652,62 @@ impl CliffordGateable for PauliProp { self } + /// Applies the square root of X gate. + /// + /// Binary Pauli action: X -> X, Z <-> Y. + #[inline] + fn sx(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + let qu = q.index(); + if self.contains_z(qu) { + self.track_x(&[qu]); + } + } + self + } + + /// Applies the adjoint square root of X gate. + /// + /// Ignoring global phase, `SX` and `SXdg` have the same binary Pauli action. + #[inline] + fn sxdg(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + let qu = q.index(); + if self.contains_z(qu) { + self.track_x(&[qu]); + } + } + self + } + + /// Applies the square root of Y gate. + /// + /// Binary Pauli action: X <-> Z, Y -> Y. + #[inline] + fn sy(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + let qu = q.index(); + let x = self.contains_x(qu); + let z = self.contains_z(qu); + self.set_components(qu, z, x); + } + self + } + + /// Applies the adjoint square root of Y gate. + /// + /// Ignoring global phase, `SY` and `SYdg` have the same binary Pauli action. + #[inline] + fn sydg(&mut self, qubits: &[QubitId]) -> &mut Self { + for &q in qubits { + let qu = q.index(); + let x = self.contains_x(qu); + let z = self.contains_z(qu); + self.set_components(qu, z, x); + } + self + } + /// Applies the controlled-X (CX) gate between pairs of qubits /// /// The CX gate transforms Pauli operators as follows: @@ -645,6 +742,160 @@ impl CliffordGateable for PauliProp { self } + /// Applies the controlled-Y gate. + #[inline] + fn cy(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + self.set_components(q1, x1, z1 ^ x2 ^ z2); + self.set_components(q2, x2 ^ x1, z2 ^ x1); + } + self + } + + /// Applies the controlled-Z gate. + #[inline] + fn cz(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + self.set_components(q1, x1, z1 ^ x2); + self.set_components(q2, x2, z2 ^ x1); + } + self + } + + /// Applies the square root of XX gate. + #[inline] + fn sxx(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + let affected = z1 ^ z2; + self.set_components(q1, x1 ^ affected, z1); + self.set_components(q2, x2 ^ affected, z2); + } + self + } + + /// Applies the adjoint square root of XX gate. + /// + /// Ignoring global phase, `SXX` and `SXXdg` have the same binary Pauli action. + #[inline] + fn sxxdg(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + let affected = z1 ^ z2; + self.set_components(q1, x1 ^ affected, z1); + self.set_components(q2, x2 ^ affected, z2); + } + self + } + + /// Applies the square root of YY gate. + #[inline] + fn syy(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + self.set_components(q1, x2 ^ z1 ^ z2, x1 ^ x2 ^ z2); + self.set_components(q2, x1 ^ z1 ^ z2, x1 ^ x2 ^ z1); + } + self + } + + /// Applies the adjoint square root of YY gate. + /// + /// Ignoring global phase, `SYY` and `SYYdg` have the same binary Pauli action. + #[inline] + fn syydg(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + self.set_components(q1, x2 ^ z1 ^ z2, x1 ^ x2 ^ z2); + self.set_components(q2, x1 ^ z1 ^ z2, x1 ^ x2 ^ z1); + } + self + } + + /// Applies the square root of ZZ gate. + #[inline] + fn szz(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + let affected = x1 ^ x2; + self.set_components(q1, x1, z1 ^ affected); + self.set_components(q2, x2, z2 ^ affected); + } + self + } + + /// Applies the adjoint square root of ZZ gate. + /// + /// Ignoring global phase, `SZZ` and `SZZdg` have the same binary Pauli action. + #[inline] + fn szzdg(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + let affected = x1 ^ x2; + self.set_components(q1, x1, z1 ^ affected); + self.set_components(q2, x2, z2 ^ affected); + } + self + } + + /// Applies the SWAP gate. + #[inline] + fn swap(&mut self, pairs: &[(QubitId, QubitId)]) -> &mut Self { + for &(q1, q2) in pairs { + let q1 = q1.index(); + let q2 = q2.index(); + let x1 = self.contains_x(q1); + let z1 = self.contains_z(q1); + let x2 = self.contains_x(q2); + let z2 = self.contains_z(q2); + self.set_components(q1, x2, z2); + self.set_components(q2, x1, z1); + } + self + } + /// Performs a Z-basis measurement on the specified qubits. /// /// This simulates the effect of Pauli operators on measurement due to propagation. @@ -680,8 +931,106 @@ impl CliffordGateable for PauliProp { #[cfg(test)] mod tests { use super::*; + use crate::clifford_matrix_oracle::{CliffordMatrixGate, all_pauli_strings, conjugate_pauli}; use std::collections::BTreeMap; + fn prop_from_dense(input: &str) -> PauliProp { + let mut prop = PauliProp::with_sign_tracking(input.len()); + for (q, p) in input.chars().enumerate() { + match p { + 'I' => {} + 'X' => prop.track_x(&[q]), + 'Y' => prop.track_y(&[q]), + 'Z' => prop.track_z(&[q]), + _ => panic!("invalid Pauli label {p}"), + } + } + prop + } + + fn assert_gate_table(name: &str, table: &[(&str, &str)], mut apply: F) + where + F: FnMut(&mut PauliProp), + { + for &(input, expected) in table { + let mut prop = prop_from_dense(input); + apply(&mut prop); + assert_eq!(prop.dense_string(), expected, "{name}: {input}"); + } + } + + fn assert_gate_matches_matrix_oracle( + name: &str, + gate: CliffordMatrixGate, + num_qubits: usize, + mut apply: F, + ) where + F: FnMut(&mut PauliProp), + { + for input in all_pauli_strings(num_qubits) { + let expected = conjugate_pauli(gate, &input); + let mut prop = prop_from_dense(&input); + apply(&mut prop); + assert_eq!( + prop.dense_string(), + expected.pauli, + "{name}: {input}, oracle sign {}", + expected.sign + ); + } + } + + fn reverse_two_qubit_pauli(pauli: &str) -> String { + let labels: Vec = pauli.chars().collect(); + assert_eq!(labels.len(), 2); + [labels[1], labels[0]].into_iter().collect() + } + + fn assert_reversed_pair_matches_matrix_oracle( + name: &str, + gate: CliffordMatrixGate, + mut apply: F, + ) where + F: FnMut(&mut PauliProp, &[(QubitId, QubitId)]), + { + let reversed_pair = [(QubitId(1), QubitId(0))]; + for input in all_pauli_strings(2) { + let oracle_input = reverse_two_qubit_pauli(&input); + let mut expected = conjugate_pauli(gate, &oracle_input); + expected.pauli = reverse_two_qubit_pauli(&expected.pauli); + + let mut prop = prop_from_dense(&input); + apply(&mut prop, &reversed_pair); + assert_eq!( + prop.dense_string(), + expected.pauli, + "{name} reversed pair: {input}, oracle sign {}", + expected.sign + ); + } + } + + fn assert_two_pair_batch_matches_sequential(name: &str, mut apply: F) + where + F: FnMut(&mut PauliProp, &[(QubitId, QubitId)]), + { + let pairs = [(QubitId(0), QubitId(1)), (QubitId(2), QubitId(3))]; + for input in all_pauli_strings(4) { + let mut batched = prop_from_dense(&input); + apply(&mut batched, &pairs); + + let mut sequential = prop_from_dense(&input); + apply(&mut sequential, &pairs[0..1]); + apply(&mut sequential, &pairs[1..2]); + + assert_eq!( + batched.dense_string(), + sequential.dense_string(), + "{name} batched: {input}" + ); + } + } + #[test] fn test_sign_tracking() { let mut sim = PauliProp::with_sign_tracking(4); @@ -785,4 +1134,265 @@ mod tests { // Phase should be -i (X·Z = -iY) assert_eq!(sim.sign_string(), "-i"); } + + #[test] + fn test_direct_clifford_gate_binary_truth_tables() { + let q0 = QubitId(0); + let q1 = QubitId(1); + let pair = [(q0, q1)]; + + assert_gate_table( + "SZdg", + &[("I", "I"), ("X", "Y"), ("Y", "X"), ("Z", "Z")], + |prop| { + prop.szdg(&[q0]); + }, + ); + assert_gate_table( + "SX", + &[("I", "I"), ("X", "X"), ("Y", "Z"), ("Z", "Y")], + |prop| { + prop.sx(&[q0]); + }, + ); + assert_gate_table( + "SXdg", + &[("I", "I"), ("X", "X"), ("Y", "Z"), ("Z", "Y")], + |prop| { + prop.sxdg(&[q0]); + }, + ); + assert_gate_table( + "SY", + &[("I", "I"), ("X", "Z"), ("Y", "Y"), ("Z", "X")], + |prop| { + prop.sy(&[q0]); + }, + ); + assert_gate_table( + "SYdg", + &[("I", "I"), ("X", "Z"), ("Y", "Y"), ("Z", "X")], + |prop| { + prop.sydg(&[q0]); + }, + ); + assert_gate_table( + "CY", + &[("XI", "XY"), ("IX", "ZX"), ("ZI", "ZI"), ("IZ", "ZZ")], + |prop| { + prop.cy(&pair); + }, + ); + assert_gate_table( + "CZ", + &[("XI", "XZ"), ("IX", "ZX"), ("ZI", "ZI"), ("IZ", "IZ")], + |prop| { + prop.cz(&pair); + }, + ); + assert_gate_table( + "SXX", + &[("XI", "XI"), ("IX", "IX"), ("ZI", "YX"), ("IZ", "XY")], + |prop| { + prop.sxx(&pair); + }, + ); + assert_gate_table( + "SXXdg", + &[("XI", "XI"), ("IX", "IX"), ("ZI", "YX"), ("IZ", "XY")], + |prop| { + prop.sxxdg(&pair); + }, + ); + assert_gate_table( + "SYY", + &[("XI", "ZY"), ("IX", "YZ"), ("ZI", "XY"), ("IZ", "YX")], + |prop| { + prop.syy(&pair); + }, + ); + assert_gate_table( + "SYYdg", + &[("XI", "ZY"), ("IX", "YZ"), ("ZI", "XY"), ("IZ", "YX")], + |prop| { + prop.syydg(&pair); + }, + ); + assert_gate_table( + "SZZ", + &[("XI", "YZ"), ("IX", "ZY"), ("ZI", "ZI"), ("IZ", "IZ")], + |prop| { + prop.szz(&pair); + }, + ); + assert_gate_table( + "SZZdg", + &[("XI", "YZ"), ("IX", "ZY"), ("ZI", "ZI"), ("IZ", "IZ")], + |prop| { + prop.szzdg(&pair); + }, + ); + assert_gate_table( + "SWAP", + &[("XI", "IX"), ("IX", "XI"), ("ZI", "IZ"), ("IZ", "ZI")], + |prop| { + prop.swap(&pair); + }, + ); + } + + #[test] + fn test_direct_clifford_gates_match_matrix_oracle_for_all_paulis() { + let q0 = QubitId(0); + let q1 = QubitId(1); + let pair = [(q0, q1)]; + + assert_gate_matches_matrix_oracle("CX", CliffordMatrixGate::CX, 2, |prop| { + prop.cx(&pair); + }); + assert_gate_matches_matrix_oracle("SZdg", CliffordMatrixGate::SZdg, 1, |prop| { + prop.szdg(&[q0]); + }); + assert_gate_matches_matrix_oracle("F", CliffordMatrixGate::F, 1, |prop| { + prop.f(&[q0]); + }); + assert_gate_matches_matrix_oracle("Fdg", CliffordMatrixGate::Fdg, 1, |prop| { + prop.fdg(&[q0]); + }); + assert_gate_matches_matrix_oracle("SX", CliffordMatrixGate::SX, 1, |prop| { + prop.sx(&[q0]); + }); + assert_gate_matches_matrix_oracle("SXdg", CliffordMatrixGate::SXdg, 1, |prop| { + prop.sxdg(&[q0]); + }); + assert_gate_matches_matrix_oracle("SY", CliffordMatrixGate::SY, 1, |prop| { + prop.sy(&[q0]); + }); + assert_gate_matches_matrix_oracle("SYdg", CliffordMatrixGate::SYdg, 1, |prop| { + prop.sydg(&[q0]); + }); + assert_gate_matches_matrix_oracle("CY", CliffordMatrixGate::CY, 2, |prop| { + prop.cy(&pair); + }); + assert_gate_matches_matrix_oracle("CZ", CliffordMatrixGate::CZ, 2, |prop| { + prop.cz(&pair); + }); + assert_gate_matches_matrix_oracle("SXX", CliffordMatrixGate::SXX, 2, |prop| { + prop.sxx(&pair); + }); + assert_gate_matches_matrix_oracle("SXXdg", CliffordMatrixGate::SXXdg, 2, |prop| { + prop.sxxdg(&pair); + }); + assert_gate_matches_matrix_oracle("SYY", CliffordMatrixGate::SYY, 2, |prop| { + prop.syy(&pair); + }); + assert_gate_matches_matrix_oracle("SYYdg", CliffordMatrixGate::SYYdg, 2, |prop| { + prop.syydg(&pair); + }); + assert_gate_matches_matrix_oracle("SZZ", CliffordMatrixGate::SZZ, 2, |prop| { + prop.szz(&pair); + }); + assert_gate_matches_matrix_oracle("SZZdg", CliffordMatrixGate::SZZdg, 2, |prop| { + prop.szzdg(&pair); + }); + assert_gate_matches_matrix_oracle("SWAP", CliffordMatrixGate::SWAP, 2, |prop| { + prop.swap(&pair); + }); + } + + #[test] + fn test_two_qubit_gates_reversed_pair_matches_matrix_oracle() { + assert_reversed_pair_matches_matrix_oracle("CX", CliffordMatrixGate::CX, |prop, pairs| { + prop.cx(pairs); + }); + assert_reversed_pair_matches_matrix_oracle("CY", CliffordMatrixGate::CY, |prop, pairs| { + prop.cy(pairs); + }); + assert_reversed_pair_matches_matrix_oracle("CZ", CliffordMatrixGate::CZ, |prop, pairs| { + prop.cz(pairs); + }); + assert_reversed_pair_matches_matrix_oracle( + "SXX", + CliffordMatrixGate::SXX, + |prop, pairs| { + prop.sxx(pairs); + }, + ); + assert_reversed_pair_matches_matrix_oracle( + "SXXdg", + CliffordMatrixGate::SXXdg, + |prop, pairs| { + prop.sxxdg(pairs); + }, + ); + assert_reversed_pair_matches_matrix_oracle( + "SYY", + CliffordMatrixGate::SYY, + |prop, pairs| { + prop.syy(pairs); + }, + ); + assert_reversed_pair_matches_matrix_oracle( + "SYYdg", + CliffordMatrixGate::SYYdg, + |prop, pairs| { + prop.syydg(pairs); + }, + ); + assert_reversed_pair_matches_matrix_oracle( + "SZZ", + CliffordMatrixGate::SZZ, + |prop, pairs| { + prop.szz(pairs); + }, + ); + assert_reversed_pair_matches_matrix_oracle( + "SZZdg", + CliffordMatrixGate::SZZdg, + |prop, pairs| { + prop.szzdg(pairs); + }, + ); + assert_reversed_pair_matches_matrix_oracle( + "SWAP", + CliffordMatrixGate::SWAP, + |prop, pairs| { + prop.swap(pairs); + }, + ); + } + + #[test] + fn test_two_qubit_gate_batches_match_sequential_pairs() { + assert_two_pair_batch_matches_sequential("CX", |prop, pairs| { + prop.cx(pairs); + }); + assert_two_pair_batch_matches_sequential("CY", |prop, pairs| { + prop.cy(pairs); + }); + assert_two_pair_batch_matches_sequential("CZ", |prop, pairs| { + prop.cz(pairs); + }); + assert_two_pair_batch_matches_sequential("SXX", |prop, pairs| { + prop.sxx(pairs); + }); + assert_two_pair_batch_matches_sequential("SXXdg", |prop, pairs| { + prop.sxxdg(pairs); + }); + assert_two_pair_batch_matches_sequential("SYY", |prop, pairs| { + prop.syy(pairs); + }); + assert_two_pair_batch_matches_sequential("SYYdg", |prop, pairs| { + prop.syydg(pairs); + }); + assert_two_pair_batch_matches_sequential("SZZ", |prop, pairs| { + prop.szz(pairs); + }); + assert_two_pair_batch_matches_sequential("SZZdg", |prop, pairs| { + prop.szzdg(pairs); + }); + assert_two_pair_batch_matches_sequential("SWAP", |prop, pairs| { + prop.swap(pairs); + }); + } } diff --git a/crates/pecos-simulators/src/quantum_simulator.rs b/crates/pecos-simulators/src/quantum_simulator.rs index 4b747d0bb..740b45116 100644 --- a/crates/pecos-simulators/src/quantum_simulator.rs +++ b/crates/pecos-simulators/src/quantum_simulator.rs @@ -19,7 +19,7 @@ pub trait QuantumSimulator { /// /// The exact meaning of reset depends on the simulator type: /// - For state vector simulators: resets to |0⟩ state - /// - For observable propagators: clears tracked operators + /// - For observable propagators: clears tracked Paulis /// - For stabilizer simulators: resets to trivial stabilizer group /// /// # Returns diff --git a/crates/pecos-simulators/src/sparse_stab.rs b/crates/pecos-simulators/src/sparse_stab.rs index 0432793aa..fd67b16e0 100644 --- a/crates/pecos-simulators/src/sparse_stab.rs +++ b/crates/pecos-simulators/src/sparse_stab.rs @@ -301,6 +301,28 @@ where self } + /// Extracts the stabilizer generators as a [`PauliStabilizerGroup`]. + /// + /// Converts the simulator's internal tableau into the algebraic + /// representation, enabling rank analysis, distance calculation, + /// logical operator computation, and other GF(2) operations. + /// + /// [`PauliStabilizerGroup`]: pecos_quantum::PauliStabilizerGroup + #[must_use] + pub fn to_stabilizer_group(&self) -> pecos_quantum::PauliStabilizerGroup { + let generators = self.stabs.generators(); + pecos_quantum::PauliStabilizerGroup::from_generators_unchecked(generators) + } + + /// Extracts the destabilizer generators as a [`PauliSequence`]. + /// + /// [`PauliSequence`]: pecos_quantum::PauliSequence + #[must_use] + pub fn to_destabilizer_sequence(&self) -> pecos_quantum::PauliSequence { + let generators = self.destabs.generators(); + pecos_quantum::PauliSequence::new(generators) + } + /// Returns generator data as sparse index vectors. /// /// Returns `(col_x, col_z, row_x, row_z)` where each is a `Vec>`. diff --git a/crates/pecos-simulators/src/stabilizer.rs b/crates/pecos-simulators/src/stabilizer.rs index b1e1b5bc6..02b7dbb50 100644 --- a/crates/pecos-simulators/src/stabilizer.rs +++ b/crates/pecos-simulators/src/stabilizer.rs @@ -112,6 +112,22 @@ impl Stabilizer { pub fn gens_data(&self, is_stab: bool) -> crate::GensData { self.inner.gens_data(is_stab) } + + /// Extracts the stabilizer generators as a [`PauliStabilizerGroup`]. + /// + /// [`PauliStabilizerGroup`]: pecos_quantum::PauliStabilizerGroup + #[must_use] + pub fn to_stabilizer_group(&self) -> pecos_quantum::PauliStabilizerGroup { + self.inner.to_stabilizer_group() + } + + /// Extracts the destabilizer generators as a [`PauliSequence`]. + /// + /// [`PauliSequence`]: pecos_quantum::PauliSequence + #[must_use] + pub fn to_destabilizer_sequence(&self) -> pecos_quantum::PauliSequence { + self.inner.to_destabilizer_sequence() + } } impl QuantumSimulator for Stabilizer { diff --git a/crates/pecos-simulators/src/state_access.rs b/crates/pecos-simulators/src/state_access.rs new file mode 100644 index 000000000..99caa1925 --- /dev/null +++ b/crates/pecos-simulators/src/state_access.rs @@ -0,0 +1,1483 @@ +// 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. + +//! Capability traits for inspecting simulator state. +//! +//! These traits deliberately describe what a backend can expose, rather than a +//! single broad "quantum state" interface. A state-vector simulator can provide +//! amplitudes; a density-matrix simulator can provide matrix elements; symbolic +//! propagation backends should not pretend to expose either. + +use crate::clifford_gateable::{CliffordGateable, MeasurementResult}; +use crate::dense_stab::DenseStab; +use crate::density_matrix::DensityMatrix; +use crate::quantum_simulator::QuantumSimulator; +use crate::sparse_stab::{SparseStabGeneric, SparseStabHybrid}; +use crate::stabilizer::Stabilizer; +use crate::state_vec_aos::StateVecAoS; +use crate::state_vec_soa::StateVecSoA; +use crate::state_vec_soa32::StateVecSoA32; +use crate::state_vec_sparse_aos::SparseStateVecAoS; +use crate::state_vec_sparse_soa::SparseStateVecSoA; +use core::fmt; +use num_complex::Complex64; +use pecos_core::{IndexSet, Pauli, PauliString, Phase, QuarterPhase, QubitId}; +use pecos_quantum::PauliStabilizerGroup; +use pecos_random::{Rng, RngExt as _, SeedableRng}; +use std::error::Error; +use std::f64::consts::TAU; +use std::fmt::Debug; + +/// Error returned by state-inspection capability traits. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum StateAccessError { + /// `2^num_qubits` cannot be represented as a `usize`. + DimensionOverflow { + /// Number of qubits requested. + num_qubits: usize, + }, + /// A computational-basis index is outside the state Hilbert space. + BasisIndexOutOfRange { + /// Number of qubits in the state. + num_qubits: usize, + /// Hilbert-space dimension, when representable. + dim: usize, + /// Offending basis index. + index: usize, + }, + /// A Pauli string acts outside the state qubit range. + PauliQubitOutOfRange { + /// Number of qubits in the state. + num_qubits: usize, + /// Offending qubit index. + qubit: usize, + }, + /// A sampled/measured qubit is outside the state qubit range. + QubitOutOfRange { + /// Number of qubits in the state. + num_qubits: usize, + /// Offending qubit index. + qubit: usize, + }, + /// A state vector had an unexpected length. + InvalidStateVectorLength { + /// Expected vector length. + expected: usize, + /// Actual vector length. + actual: usize, + }, + /// A density matrix had an unexpected shape. + InvalidDensityMatrixShape { + /// Expected row/column count. + expected: usize, + /// Actual row count. + rows: usize, + /// Actual column count. + cols: usize, + }, + /// Stabilizer generators do not define a unique pure state. + NotPureStabilizerState { + /// Number of qubits in the represented system. + num_qubits: usize, + /// Number of supplied stabilizer generators. + num_generators: usize, + /// Rank of the stabilizer generator span. + rank: usize, + }, +} + +impl fmt::Display for StateAccessError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + match self { + Self::DimensionOverflow { num_qubits } => { + write!( + f, + "Hilbert dimension overflows usize for {num_qubits} qubits" + ) + } + Self::BasisIndexOutOfRange { + num_qubits, + dim, + index, + } => write!( + f, + "basis index {index} is outside the {dim}-element Hilbert space for {num_qubits} qubits" + ), + Self::PauliQubitOutOfRange { num_qubits, qubit } => write!( + f, + "Pauli string touches qubit {qubit}, outside {num_qubits}-qubit state" + ), + Self::QubitOutOfRange { num_qubits, qubit } => { + write!(f, "qubit {qubit} is outside {num_qubits}-qubit state") + } + Self::InvalidStateVectorLength { expected, actual } => { + write!( + f, + "invalid state-vector length {actual}; expected {expected}" + ) + } + Self::InvalidDensityMatrixShape { + expected, + rows, + cols, + } => write!( + f, + "invalid density-matrix shape {rows}x{cols}; expected {expected}x{expected}" + ), + Self::NotPureStabilizerState { + num_qubits, + num_generators, + rank, + } => write!( + f, + "stabilizer generators do not define a unique pure state for {num_qubits} qubits: {num_generators} generators, rank {rank}" + ), + } + } +} + +impl Error for StateAccessError {} + +/// Common metadata for state-inspection capabilities. +pub trait StateInfo { + /// Returns the number of qubits represented by the state. + fn num_qubits(&self) -> usize; + + /// Returns the Hilbert-space dimension `2^num_qubits`. + /// + /// # Errors + /// + /// Returns [`StateAccessError::DimensionOverflow`] if the dimension cannot + /// be represented as a `usize`. + fn hilbert_dim(&self) -> Result { + hilbert_dim(self.num_qubits()) + } +} + +impl StateInfo for T +where + T: QuantumSimulator, +{ + fn num_qubits(&self) -> usize { + QuantumSimulator::num_qubits(self) + } +} + +/// Returns a Haar-random pure state vector on `num_qubits` qubits. +/// +/// The vector is sampled by drawing independent complex normal amplitudes and +/// normalizing them. The output is in little-endian computational-basis order, +/// matching PECOS's state-vector backends. +/// +/// # Errors +/// +/// Returns [`StateAccessError::DimensionOverflow`] if `2^num_qubits` does not +/// fit in `usize`. +pub fn random_statevector( + rng: &mut R, + num_qubits: usize, +) -> Result, StateAccessError> +where + R: Rng + ?Sized, +{ + let dim = hilbert_dim(num_qubits)?; + if dim == 1 { + return Ok(vec![Complex64::new(1.0, 0.0)]); + } + + loop { + let mut state: Vec = (0..dim).map(|_| standard_complex_normal(rng)).collect(); + let norm_sqr = state_norm_sqr(&state); + if norm_sqr > f64::EPSILON { + normalize_state_vector(&mut state, norm_sqr); + return Ok(state); + } + } +} + +/// Capability for backends that can expose computational-basis amplitudes. +pub trait StateVectorAccess: StateInfo { + /// Returns one computational-basis amplitude. + /// + /// # Errors + /// + /// Returns an error if `basis_state` is outside the Hilbert space. + fn amplitude(&mut self, basis_state: usize) -> Result; + + /// Returns a dense state vector in little-endian computational-basis order. + /// + /// # Errors + /// + /// Returns an error if the Hilbert-space dimension overflows. + fn state_vector(&mut self) -> Result, StateAccessError>; + + /// Returns the probability of one computational-basis state. + /// + /// # Errors + /// + /// Returns an error if `basis_state` is outside the Hilbert space. + fn basis_probability(&mut self, basis_state: usize) -> Result { + Ok(self.amplitude(basis_state)?.norm_sqr()) + } +} + +/// Capability for backends that can expose a density matrix. +pub trait DensityMatrixAccess: StateInfo { + /// Returns a dense density matrix in little-endian computational-basis order. + /// + /// # Errors + /// + /// Returns an error if the Hilbert-space dimension overflows. + fn density_matrix(&mut self) -> Result>, StateAccessError>; + + /// Returns one density-matrix element. + /// + /// The default implementation materializes the full dense density matrix + /// and then reads one entry. Backends with cheaper direct element access + /// should override this method. + /// + /// # Errors + /// + /// Returns an error if either index is outside the Hilbert space. + fn density_matrix_element( + &mut self, + row: usize, + col: usize, + ) -> Result { + validate_basis_index(self.num_qubits(), row)?; + validate_basis_index(self.num_qubits(), col)?; + let rho = self.density_matrix()?; + Ok(rho[row][col]) + } + + /// Returns the probability of one computational-basis state. + /// + /// # Errors + /// + /// Returns an error if `basis_state` is outside the Hilbert space. + fn basis_probability(&mut self, basis_state: usize) -> Result { + Ok(self.density_matrix_element(basis_state, basis_state)?.re) + } +} + +/// Capability for backends that can evaluate Pauli expectation values. +pub trait PauliExpectationAccess: StateInfo { + /// Returns `

` for the supplied Pauli string. + /// + /// The result is complex so callers can also query phased Pauli strings + /// such as `i X`. For Hermitian Paulis with real phase, the imaginary part + /// should be numerical roundoff only. + /// + /// # Errors + /// + /// Returns an error if the Pauli string acts outside the state. + fn pauli_expectation(&mut self, pauli: &PauliString) -> Result; +} + +/// Capability for backends that can sample projective Pauli-basis measurements. +/// +/// Unlike [`StateVectorAccess`], [`DensityMatrixAccess`], and +/// [`PauliExpectationAccess`], these methods are not passive inspection: +/// sampling mutates the backend by collapsing the measured state and may +/// consume RNG state. +pub trait StateSampling: StateInfo { + /// Samples/measures qubits in the computational (`Z`) basis. + /// + /// Results use the existing PECOS [`MeasurementResult`] convention: + /// `outcome == false` is the `0`/`+Z` outcome and `outcome == true` is + /// the `1`/`-Z` outcome. + /// + /// # Errors + /// + /// Returns an error if any qubit is outside the state. + fn sample_z(&mut self, qubits: &[QubitId]) -> Result, StateAccessError>; + + /// Samples/measures qubits in the `X` basis. + /// + /// # Errors + /// + /// Returns an error if any qubit is outside the state. + fn sample_x(&mut self, qubits: &[QubitId]) -> Result, StateAccessError>; + + /// Samples/measures qubits in the `Y` basis. + /// + /// # Errors + /// + /// Returns an error if any qubit is outside the state. + fn sample_y(&mut self, qubits: &[QubitId]) -> Result, StateAccessError>; +} + +/// Capability for stabilizer backends that can materialize a dense state vector. +/// +/// This is an explicit conversion, not passive inspection. The output has +/// length `2^num_qubits`, so callers should treat it as an exponential-cost +/// debugging and interop path rather than the normal way to query stabilizer +/// states. +pub trait StabilizerStateVectorConversion: StateInfo { + /// Converts the stabilizer state into a dense state vector in little-endian + /// computational-basis order. + /// + /// The returned vector is normalized and has a deterministic global phase: + /// the first nonzero amplitude is real and positive. + /// + /// # Errors + /// + /// Returns an error if the Hilbert-space dimension overflows or if the + /// stabilizer generators do not define a unique pure state. + fn to_state_vector(&self) -> Result, StateAccessError>; +} + +macro_rules! impl_state_sampling { + (impl<$($generic:tt),+> for $ty:ty where $($where_clause:tt)*) => { + impl<$($generic),+> StateSampling for $ty + where + $($where_clause)* + { + fn sample_z( + &mut self, + qubits: &[QubitId], + ) -> Result, StateAccessError> { + sample_z_via_clifford(self, qubits) + } + + fn sample_x( + &mut self, + qubits: &[QubitId], + ) -> Result, StateAccessError> { + sample_x_via_clifford(self, qubits) + } + + fn sample_y( + &mut self, + qubits: &[QubitId], + ) -> Result, StateAccessError> { + sample_y_via_clifford(self, qubits) + } + } + }; + (for $ty:ty) => { + impl StateSampling for $ty { + fn sample_z( + &mut self, + qubits: &[QubitId], + ) -> Result, StateAccessError> { + sample_z_via_clifford(self, qubits) + } + + fn sample_x( + &mut self, + qubits: &[QubitId], + ) -> Result, StateAccessError> { + sample_x_via_clifford(self, qubits) + } + + fn sample_y( + &mut self, + qubits: &[QubitId], + ) -> Result, StateAccessError> { + sample_y_via_clifford(self, qubits) + } + } + }; +} + +impl_state_sampling!(impl for StateVecSoA where R: Rng); +impl_state_sampling!(impl for SparseStateVecSoA where R: Rng + Debug); +impl_state_sampling!(impl for StateVecAoS where R: Rng + SeedableRng + Debug); +impl_state_sampling!(impl for SparseStateVecAoS where R: Rng + Debug); +impl_state_sampling!(impl for StateVecSoA32 where R: Rng); +impl_state_sampling!(impl for DensityMatrix where R: Rng + SeedableRng + Debug + Clone); +impl_state_sampling!(impl for SparseStabGeneric where S: IndexSet, R: Rng + SeedableRng + Debug); +impl_state_sampling!(impl for SparseStabHybrid where R: Rng + SeedableRng + Debug); +impl_state_sampling!(for Stabilizer); + +impl StabilizerStateVectorConversion for SparseStabGeneric +where + S: IndexSet, + R: Rng + SeedableRng + Debug, +{ + fn to_state_vector(&self) -> Result, StateAccessError> { + stabilizer_group_to_state_vector(&self.to_stabilizer_group()) + } +} + +impl StabilizerStateVectorConversion for SparseStabHybrid +where + R: Rng + SeedableRng + Debug, +{ + fn to_state_vector(&self) -> Result, StateAccessError> { + stabilizer_group_to_state_vector(&self.to_stabilizer_group()) + } +} + +impl StabilizerStateVectorConversion for DenseStab +where + R: Rng + SeedableRng + Debug + Clone, +{ + fn to_state_vector(&self) -> Result, StateAccessError> { + stabilizer_group_to_state_vector(&self.to_stabilizer_group()) + } +} + +impl StabilizerStateVectorConversion for Stabilizer { + fn to_state_vector(&self) -> Result, StateAccessError> { + stabilizer_group_to_state_vector(&self.to_stabilizer_group()) + } +} + +impl StateVectorAccess for StateVecSoA +where + R: Rng, +{ + fn amplitude(&mut self, basis_state: usize) -> Result { + validate_basis_index(StateInfo::num_qubits(self), basis_state)?; + Ok(self.get_amplitude(basis_state)) + } + + fn state_vector(&mut self) -> Result, StateAccessError> { + let expected = self.hilbert_dim()?; + let state = self.state(); + validate_state_vector_len(&state, expected)?; + Ok(state) + } + + fn basis_probability(&mut self, basis_state: usize) -> Result { + validate_basis_index(StateInfo::num_qubits(self), basis_state)?; + Ok(self.probability(basis_state)) + } +} + +impl PauliExpectationAccess for StateVecSoA +where + R: Rng, +{ + fn pauli_expectation(&mut self, pauli: &PauliString) -> Result { + let num_qubits = StateInfo::num_qubits(self); + pauli_expectation_from_state_vector(&self.state_vector()?, num_qubits, pauli) + } +} + +impl StateVectorAccess for SparseStateVecSoA +where + R: Rng + Debug, +{ + fn amplitude(&mut self, basis_state: usize) -> Result { + validate_basis_index(StateInfo::num_qubits(self), basis_state)?; + Ok(self.get_amplitude(basis_state)) + } + + fn state_vector(&mut self) -> Result, StateAccessError> { + let expected = self.hilbert_dim()?; + let state = self.state(); + validate_state_vector_len(&state, expected)?; + Ok(state) + } + + fn basis_probability(&mut self, basis_state: usize) -> Result { + validate_basis_index(StateInfo::num_qubits(self), basis_state)?; + Ok(self.probability(basis_state)) + } +} + +impl PauliExpectationAccess for SparseStateVecSoA +where + R: Rng + Debug, +{ + fn pauli_expectation(&mut self, pauli: &PauliString) -> Result { + let num_qubits = StateInfo::num_qubits(self); + pauli_expectation_from_state_vector(&self.state_vector()?, num_qubits, pauli) + } +} + +impl StateVectorAccess for StateVecAoS +where + R: Rng + SeedableRng + Debug, +{ + fn amplitude(&mut self, basis_state: usize) -> Result { + validate_basis_index(StateInfo::num_qubits(self), basis_state)?; + Ok(self.state()[basis_state]) + } + + fn state_vector(&mut self) -> Result, StateAccessError> { + let expected = self.hilbert_dim()?; + let state = self.state().to_vec(); + validate_state_vector_len(&state, expected)?; + Ok(state) + } + + fn basis_probability(&mut self, basis_state: usize) -> Result { + validate_basis_index(StateInfo::num_qubits(self), basis_state)?; + Ok(self.probability(basis_state)) + } +} + +impl PauliExpectationAccess for StateVecAoS +where + R: Rng + SeedableRng + Debug, +{ + fn pauli_expectation(&mut self, pauli: &PauliString) -> Result { + let num_qubits = StateInfo::num_qubits(self); + pauli_expectation_from_state_vector(&self.state_vector()?, num_qubits, pauli) + } +} + +impl StateVectorAccess for SparseStateVecAoS +where + R: Rng + Debug, +{ + fn amplitude(&mut self, basis_state: usize) -> Result { + validate_basis_index(StateInfo::num_qubits(self), basis_state)?; + Ok(self.get_amplitude(basis_state)) + } + + fn state_vector(&mut self) -> Result, StateAccessError> { + let dim = self.hilbert_dim()?; + let mut state = vec![Complex64::new(0.0, 0.0); dim]; + for (basis_state, amp) in state.iter_mut().enumerate() { + *amp = self.get_amplitude(basis_state); + } + Ok(state) + } +} + +impl PauliExpectationAccess for SparseStateVecAoS +where + R: Rng + Debug, +{ + fn pauli_expectation(&mut self, pauli: &PauliString) -> Result { + let num_qubits = StateInfo::num_qubits(self); + pauli_expectation_from_state_vector(&self.state_vector()?, num_qubits, pauli) + } +} + +impl StateVectorAccess for StateVecSoA32 +where + R: Rng, +{ + fn amplitude(&mut self, basis_state: usize) -> Result { + validate_basis_index(StateInfo::num_qubits(self), basis_state)?; + let amp = self.get_amplitude(basis_state); + Ok(Complex64::new(f64::from(amp.re), f64::from(amp.im))) + } + + fn state_vector(&mut self) -> Result, StateAccessError> { + let expected = self.hilbert_dim()?; + let state: Vec = self + .to_complex_vec() + .into_iter() + .map(|amp| Complex64::new(f64::from(amp.re), f64::from(amp.im))) + .collect(); + validate_state_vector_len(&state, expected)?; + Ok(state) + } + + fn basis_probability(&mut self, basis_state: usize) -> Result { + validate_basis_index(StateInfo::num_qubits(self), basis_state)?; + Ok(self.probability(basis_state)) + } +} + +impl PauliExpectationAccess for StateVecSoA32 +where + R: Rng, +{ + fn pauli_expectation(&mut self, pauli: &PauliString) -> Result { + let num_qubits = StateInfo::num_qubits(self); + pauli_expectation_from_state_vector(&self.state_vector()?, num_qubits, pauli) + } +} + +impl DensityMatrixAccess for DensityMatrix +where + R: Rng + SeedableRng + Debug + Clone, +{ + fn density_matrix(&mut self) -> Result>, StateAccessError> { + let expected = self.hilbert_dim()?; + let rho = self.get_density_matrix(); + validate_density_matrix_shape(&rho, expected)?; + Ok(rho) + } + + fn basis_probability(&mut self, basis_state: usize) -> Result { + validate_basis_index(StateInfo::num_qubits(self), basis_state)?; + Ok(self.probability(basis_state)) + } +} + +impl PauliExpectationAccess for DensityMatrix +where + R: Rng + SeedableRng + Debug + Clone, +{ + fn pauli_expectation(&mut self, pauli: &PauliString) -> Result { + let num_qubits = StateInfo::num_qubits(self); + pauli_expectation_from_density_matrix(&self.density_matrix()?, num_qubits, pauli) + } +} + +impl PauliExpectationAccess for SparseStabGeneric +where + S: IndexSet, + R: Rng + SeedableRng + Debug, +{ + fn pauli_expectation(&mut self, pauli: &PauliString) -> Result { + pauli_expectation_from_stabilizer_group( + &self.to_stabilizer_group(), + StateInfo::num_qubits(self), + pauli, + ) + } +} + +impl PauliExpectationAccess for SparseStabHybrid +where + R: Rng + SeedableRng + Debug, +{ + fn pauli_expectation(&mut self, pauli: &PauliString) -> Result { + pauli_expectation_from_stabilizer_group( + &self.to_stabilizer_group(), + StateInfo::num_qubits(self), + pauli, + ) + } +} + +impl PauliExpectationAccess for DenseStab +where + R: Rng + SeedableRng + Debug + Clone, +{ + fn pauli_expectation(&mut self, pauli: &PauliString) -> Result { + pauli_expectation_from_stabilizer_group( + &self.to_stabilizer_group(), + StateInfo::num_qubits(self), + pauli, + ) + } +} + +impl PauliExpectationAccess for Stabilizer { + fn pauli_expectation(&mut self, pauli: &PauliString) -> Result { + pauli_expectation_from_stabilizer_group( + &self.to_stabilizer_group(), + StateInfo::num_qubits(self), + pauli, + ) + } +} + +fn hilbert_dim(num_qubits: usize) -> Result { + let shift = u32::try_from(num_qubits) + .map_err(|_| StateAccessError::DimensionOverflow { num_qubits })?; + if shift >= usize::BITS { + return Err(StateAccessError::DimensionOverflow { num_qubits }); + } + Ok(1usize << shift) +} + +fn validate_basis_index(num_qubits: usize, index: usize) -> Result { + let dim = hilbert_dim(num_qubits)?; + if index >= dim { + return Err(StateAccessError::BasisIndexOutOfRange { + num_qubits, + dim, + index, + }); + } + Ok(dim) +} + +fn validate_pauli_support(num_qubits: usize, pauli: &PauliString) -> Result<(), StateAccessError> { + for qubit in pauli.qubits() { + if qubit >= num_qubits { + return Err(StateAccessError::PauliQubitOutOfRange { num_qubits, qubit }); + } + } + Ok(()) +} + +fn validate_qubit_support(num_qubits: usize, qubits: &[QubitId]) -> Result<(), StateAccessError> { + for qubit in qubits { + let q = qubit.index(); + if q >= num_qubits { + return Err(StateAccessError::QubitOutOfRange { + num_qubits, + qubit: q, + }); + } + } + Ok(()) +} + +fn validate_state_vector_len(state: &[Complex64], expected: usize) -> Result<(), StateAccessError> { + if state.len() != expected { + return Err(StateAccessError::InvalidStateVectorLength { + expected, + actual: state.len(), + }); + } + Ok(()) +} + +fn validate_density_matrix_shape( + rho: &[Vec], + expected: usize, +) -> Result<(), StateAccessError> { + if rho.len() != expected { + return Err(StateAccessError::InvalidDensityMatrixShape { + expected, + rows: rho.len(), + cols: rho.first().map_or(0, Vec::len), + }); + } + for row in rho { + if row.len() != expected { + return Err(StateAccessError::InvalidDensityMatrixShape { + expected, + rows: rho.len(), + cols: row.len(), + }); + } + } + Ok(()) +} + +fn sample_z_via_clifford( + backend: &mut T, + qubits: &[QubitId], +) -> Result, StateAccessError> +where + T: CliffordGateable + StateInfo, +{ + validate_qubit_support(StateInfo::num_qubits(backend), qubits)?; + Ok(backend.mz(qubits)) +} + +fn sample_x_via_clifford( + backend: &mut T, + qubits: &[QubitId], +) -> Result, StateAccessError> +where + T: CliffordGateable + StateInfo, +{ + validate_qubit_support(StateInfo::num_qubits(backend), qubits)?; + Ok(backend.mx(qubits)) +} + +fn sample_y_via_clifford( + backend: &mut T, + qubits: &[QubitId], +) -> Result, StateAccessError> +where + T: CliffordGateable + StateInfo, +{ + validate_qubit_support(StateInfo::num_qubits(backend), qubits)?; + Ok(backend.my(qubits)) +} + +fn stabilizer_group_to_state_vector( + group: &PauliStabilizerGroup, +) -> Result, StateAccessError> { + let num_qubits = group.num_qubits(); + let num_generators = group.num_generators(); + let rank = group.rank(); + if rank != num_qubits { + return Err(StateAccessError::NotPureStabilizerState { + num_qubits, + num_generators, + rank, + }); + } + + let dim = hilbert_dim(num_qubits)?; + let generators = group.stabilizers(); + for seed in 0..dim { + let mut state = vec![Complex64::new(0.0, 0.0); dim]; + state[seed] = Complex64::new(1.0, 0.0); + + for generator in generators { + validate_pauli_support(num_qubits, generator)?; + apply_stabilizer_projector(&mut state, generator); + if state_norm_sqr(&state) <= f64::EPSILON { + break; + } + } + + let norm_sqr = state_norm_sqr(&state); + if norm_sqr > f64::EPSILON { + normalize_state_vector(&mut state, norm_sqr); + canonicalize_global_phase(&mut state); + return Ok(state); + } + } + + Err(StateAccessError::NotPureStabilizerState { + num_qubits, + num_generators, + rank, + }) +} + +fn apply_stabilizer_projector(state: &mut [Complex64], generator: &PauliString) { + let input = state.to_vec(); + state.fill(Complex64::new(0.0, 0.0)); + for (basis_state, amplitude) in input.iter().copied().enumerate() { + state[basis_state] += amplitude * 0.5; + if amplitude.norm_sqr() == 0.0 { + continue; + } + let (output_state, coefficient) = pauli_action_on_basis_index(generator, basis_state); + state[output_state] += coefficient * amplitude * 0.5; + } +} + +fn state_norm_sqr(state: &[Complex64]) -> f64 { + state.iter().map(Complex64::norm_sqr).sum() +} + +fn standard_complex_normal(rng: &mut R) -> Complex64 +where + R: Rng + ?Sized, +{ + Complex64::new(standard_normal(rng), standard_normal(rng)) +} + +fn standard_normal(rng: &mut R) -> f64 +where + R: Rng + ?Sized, +{ + loop { + let u1 = rng.random::(); + if u1 > 0.0 { + let u2 = rng.random::(); + return (-2.0 * u1.ln()).sqrt() * (TAU * u2).cos(); + } + } +} + +fn normalize_state_vector(state: &mut [Complex64], norm_sqr: f64) { + let scale = norm_sqr.sqrt(); + for amplitude in state { + *amplitude /= scale; + } +} + +fn canonicalize_global_phase(state: &mut [Complex64]) { + let Some(first_nonzero) = state + .iter() + .copied() + .find(|amplitude| amplitude.norm_sqr() > f64::EPSILON) + else { + return; + }; + let phase = first_nonzero / Complex64::new(first_nonzero.norm(), 0.0); + let correction = phase.conj(); + for amplitude in state { + *amplitude *= correction; + } +} + +fn pauli_action_on_basis_index(pauli: &PauliString, basis_state: usize) -> (usize, Complex64) { + let mut output = basis_state; + let mut coefficient = pauli.phase().to_complex(); + for (single, qubit) in pauli.iter_pairs() { + let q = qubit.index(); + let bit = (basis_state >> q) & 1; + match single { + Pauli::I => {} + Pauli::X => { + output ^= 1usize << q; + } + Pauli::Y => { + output ^= 1usize << q; + coefficient *= if bit == 0 { + Complex64::new(0.0, 1.0) + } else { + Complex64::new(0.0, -1.0) + }; + } + Pauli::Z => { + if bit == 1 { + coefficient = -coefficient; + } + } + } + } + (output, coefficient) +} + +fn pauli_expectation_from_state_vector( + state: &[Complex64], + num_qubits: usize, + pauli: &PauliString, +) -> Result { + validate_pauli_support(num_qubits, pauli)?; + let dim = hilbert_dim(num_qubits)?; + validate_state_vector_len(state, dim)?; + let mut expectation = Complex64::new(0.0, 0.0); + for basis_state in 0..dim { + let (output, coefficient) = pauli_action_on_basis_index(pauli, basis_state); + expectation += coefficient * state[basis_state] * state[output].conj(); + } + Ok(expectation) +} + +fn pauli_expectation_from_density_matrix( + rho: &[Vec], + num_qubits: usize, + pauli: &PauliString, +) -> Result { + validate_pauli_support(num_qubits, pauli)?; + let dim = hilbert_dim(num_qubits)?; + validate_density_matrix_shape(rho, dim)?; + let mut expectation = Complex64::new(0.0, 0.0); + for (basis_state, rho_row) in rho.iter().enumerate().take(dim) { + let (output, coefficient) = pauli_action_on_basis_index(pauli, basis_state); + expectation += coefficient * rho_row[output]; + } + Ok(expectation) +} + +fn pauli_expectation_from_stabilizer_group( + stabilizers: &PauliStabilizerGroup, + num_qubits: usize, + pauli: &PauliString, +) -> Result { + validate_pauli_support(num_qubits, pauli)?; + + let mut positive_body = pauli.clone(); + positive_body.set_phase(QuarterPhase::PlusOne); + if !stabilizers.contains(&positive_body) { + return Ok(Complex64::new(0.0, 0.0)); + } + + let stabilizer_phase = if stabilizers.contains_with_phase(&positive_body) { + QuarterPhase::PlusOne + } else { + QuarterPhase::MinusOne + }; + Ok(pauli + .phase() + .multiply(&stabilizer_phase.conjugate()) + .to_complex()) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::{ + CliffordGateable, DenseStab, SparseStab, SparseStabHybrid, Stabilizer, StateVec, qid, + }; + use pecos_core::QubitId; + use pecos_core::pauli::algebra::i; + use pecos_core::pauli::*; + use pecos_random::PecosRng; + + fn assert_close(actual: Complex64, expected: Complex64) { + assert!( + (actual - expected).norm() < 1e-10, + "actual={actual}, expected={expected}" + ); + } + + fn assert_state_vectors_close(actual: &[Complex64], expected: &[Complex64]) { + assert_eq!(actual.len(), expected.len()); + for (index, (&actual_amp, &expected_amp)) in actual.iter().zip(expected).enumerate() { + assert!( + (actual_amp - expected_amp).norm() < 1e-10, + "state-vector mismatch at basis {index}: actual={actual_amp}, expected={expected_amp}" + ); + } + } + + #[test] + fn state_vector_access_flushes_pending_gates() { + let mut state = StateVecSoA::new(1); + state.h(&qid(0)); + + let amp0 = StateVectorAccess::amplitude(&mut state, 0).unwrap(); + let amp1 = StateVectorAccess::amplitude(&mut state, 1).unwrap(); + let expected = 1.0 / 2.0_f64.sqrt(); + assert_close(amp0, Complex64::new(expected, 0.0)); + assert_close(amp1, Complex64::new(expected, 0.0)); + assert!((StateVectorAccess::basis_probability(&mut state, 0).unwrap() - 0.5).abs() < 1e-10); + } + + #[test] + fn random_statevector_is_normalized_and_seed_reproducible() { + let mut rng = PecosRng::seed_from_u64(123); + let state = random_statevector(&mut rng, 3).unwrap(); + assert_eq!(state.len(), 8); + assert!((state_norm_sqr(&state) - 1.0).abs() < 1e-12); + + let mut same_seed = PecosRng::seed_from_u64(123); + let same = random_statevector(&mut same_seed, 3).unwrap(); + assert_eq!(state, same); + + let mut different_seed = PecosRng::seed_from_u64(456); + let different = random_statevector(&mut different_seed, 3).unwrap(); + assert_ne!(state, different); + } + + #[test] + fn random_statevector_zero_qubits_is_scalar_identity_state() { + let mut rng = PecosRng::seed_from_u64(123); + let state = random_statevector(&mut rng, 0).unwrap(); + assert_state_vectors_close(&state, &[Complex64::new(1.0, 0.0)]); + } + + #[test] + fn random_statevector_haar_marginal_is_reasonable() { + let mut rng = PecosRng::seed_from_u64(123); + let samples = 2_000; + let mut p0_sum = 0.0; + for _ in 0..samples { + let state = random_statevector(&mut rng, 2).unwrap(); + p0_sum += state[0].norm_sqr(); + } + let mean = p0_sum / f64::from(samples); + assert!( + (0.22..0.28).contains(&mean), + "expected E[|psi_0|^2] near 1/4 for a 4D Haar state, got {mean}" + ); + } + + #[test] + fn sparse_state_vector_access_returns_dense_vector() { + let mut state = StateVec::new(2); + state.x(&qid(1)); + + let dense = state.state_vector().unwrap(); + assert_eq!(dense.len(), 4); + assert_close(dense[0], Complex64::new(0.0, 0.0)); + assert_close(dense[2], Complex64::new(1.0, 0.0)); + } + + #[test] + fn all_state_vector_backends_expose_amplitudes() { + let mut dense_soa = StateVecSoA::new(1); + let mut dense_aos = StateVecAoS::new(1); + let mut sparse_soa = SparseStateVecSoA::new(1); + let mut sparse_aos = SparseStateVecAoS::new(1); + let mut soa32 = StateVecSoA32::new(1); + + dense_soa.x(&qid(0)); + dense_aos.x(&qid(0)); + sparse_soa.x(&qid(0)); + sparse_aos.x(&qid(0)); + soa32.x(&qid(0)); + + assert_close(dense_soa.amplitude(1).unwrap(), Complex64::new(1.0, 0.0)); + assert_close(dense_aos.amplitude(1).unwrap(), Complex64::new(1.0, 0.0)); + assert_close(sparse_soa.amplitude(1).unwrap(), Complex64::new(1.0, 0.0)); + assert_close(sparse_aos.amplitude(1).unwrap(), Complex64::new(1.0, 0.0)); + assert_close(soa32.amplitude(1).unwrap(), Complex64::new(1.0, 0.0)); + } + + #[test] + fn state_vector_pauli_expectations_match_known_states() { + let mut plus = StateVec::new(1); + plus.h(&qid(0)); + + assert_close( + plus.pauli_expectation(&X(0)).unwrap(), + Complex64::new(1.0, 0.0), + ); + assert_close( + plus.pauli_expectation(&Z(0)).unwrap(), + Complex64::new(0.0, 0.0), + ); + assert_close( + plus.pauli_expectation(&(i * X(0))).unwrap(), + Complex64::new(0.0, 1.0), + ); + } + + #[test] + fn density_matrix_access_and_expectation_match_known_state() { + let mut state = DensityMatrix::new(1); + state.h(&qid(0)); + + let rho = state.density_matrix().unwrap(); + assert_eq!(rho.len(), 2); + assert_close(rho[0][0], Complex64::new(0.5, 0.0)); + assert_close(rho[0][1], Complex64::new(0.5, 0.0)); + assert!( + (DensityMatrixAccess::basis_probability(&mut state, 0).unwrap() - 0.5).abs() < 1e-10 + ); + assert_close( + state.pauli_expectation(&X(0)).unwrap(), + Complex64::new(1.0, 0.0), + ); + assert_close( + state.pauli_expectation(&Z(0)).unwrap(), + Complex64::new(0.0, 0.0), + ); + } + + #[test] + fn sparse_stab_pauli_expectation_uses_signed_stabilizer_membership() { + let mut one = SparseStab::new(1); + one.x(&qid(0)); + + assert_close( + one.pauli_expectation(&Z(0)).unwrap(), + Complex64::new(-1.0, 0.0), + ); + assert_close( + one.pauli_expectation(&(-Z(0))).unwrap(), + Complex64::new(1.0, 0.0), + ); + assert_close( + one.pauli_expectation(&(i * Z(0))).unwrap(), + Complex64::new(0.0, -1.0), + ); + assert_close( + one.pauli_expectation(&X(0)).unwrap(), + Complex64::new(0.0, 0.0), + ); + } + + #[test] + fn sparse_stab_pauli_expectation_matches_state_vector_for_bell_state() { + let mut state_vec = StateVec::new(2); + let mut stab = SparseStab::new(2); + state_vec.h(&qid(0)).cx(&[(QubitId(0), QubitId(1))]); + stab.h(&qid(0)).cx(&[(QubitId(0), QubitId(1))]); + + for pauli in [ + X(0) & X(1), + Y(0) & Y(1), + Z(0) & Z(1), + X(0) & Z(1), + Y(0) & Z(1), + X(0) & Y(1), + X(0), + Z(0), + ] { + let expected = state_vec.pauli_expectation(&pauli).unwrap(); + let actual = stab.pauli_expectation(&pauli).unwrap(); + assert_close(actual, expected); + } + } + + #[test] + fn dense_stab_pauli_expectation_matches_state_vector_for_bell_state() { + let mut state_vec = StateVec::new(2); + let mut dense = DenseStab::new(2); + state_vec.h(&qid(0)).cx(&[(QubitId(0), QubitId(1))]); + dense.h(&qid(0)).cx(&[(QubitId(0), QubitId(1))]); + + for pauli in [ + X(0) & X(1), + Y(0) & Y(1), + Z(0) & Z(1), + X(0) & Z(1), + Y(0) & Z(1), + X(0) & Y(1), + X(0), + Z(0), + ] { + let expected = state_vec.pauli_expectation(&pauli).unwrap(); + let actual = dense.pauli_expectation(&pauli).unwrap(); + assert_close(actual, expected); + } + } + + #[test] + fn stabilizer_pauli_expectations_match_state_vector_for_three_qubit_ghz() { + let mut state_vec = StateVec::new(3); + let mut sparse = SparseStab::new(3); + let mut dense = DenseStab::new(3); + let pairs = [(QubitId(0), QubitId(1)), (QubitId(0), QubitId(2))]; + state_vec.h(&qid(0)).cx(&pairs); + sparse.h(&qid(0)).cx(&pairs); + dense.h(&qid(0)).cx(&pairs); + + for pauli in [ + X(0) & X(1) & X(2), + Z(0) & Z(1), + Z(1) & Z(2), + Z(0) & Z(2), + Y(0) & Y(1) & X(2), + X(0), + Z(0), + ] { + let expected = state_vec.pauli_expectation(&pauli).unwrap(); + let sparse_actual = sparse.pauli_expectation(&pauli).unwrap(); + let dense_actual = dense.pauli_expectation(&pauli).unwrap(); + assert_close(sparse_actual, expected); + assert_close(dense_actual, expected); + } + } + + #[test] + fn sparse_stab_hybrid_supports_pauli_expectation_access() { + let mut plus = SparseStabHybrid::new(1); + plus.h(&qid(0)); + + assert_close( + plus.pauli_expectation(&X(0)).unwrap(), + Complex64::new(1.0, 0.0), + ); + assert_close( + plus.pauli_expectation(&Z(0)).unwrap(), + Complex64::new(0.0, 0.0), + ); + assert_close( + plus.pauli_expectation(&(i * X(0))).unwrap(), + Complex64::new(0.0, 1.0), + ); + } + + #[test] + fn stabilizer_generator_bridge_preserves_y_phase_convention() { + let mut sparse = SparseStab::new(1); + sparse.h(&qid(0)).sz(&qid(0)); + + assert_close( + sparse.pauli_expectation(&Y(0)).unwrap(), + Complex64::new(1.0, 0.0), + ); + assert_close( + sparse.pauli_expectation(&X(0)).unwrap(), + Complex64::new(0.0, 0.0), + ); + + let mut hybrid = SparseStabHybrid::new(1); + hybrid.h(&qid(0)).sz(&qid(0)); + assert_close( + hybrid.pauli_expectation(&Y(0)).unwrap(), + Complex64::new(1.0, 0.0), + ); + + let mut dense = DenseStab::new(1); + dense.h(&qid(0)).sz(&qid(0)); + assert_close( + dense.pauli_expectation(&Y(0)).unwrap(), + Complex64::new(1.0, 0.0), + ); + assert_close( + dense.pauli_expectation(&X(0)).unwrap(), + Complex64::new(0.0, 0.0), + ); + } + + #[test] + fn dense_stab_pauli_expectation_preserves_signed_basis_state() { + let mut one = DenseStab::new(1); + one.x(&qid(0)); + + assert_close( + one.pauli_expectation(&Z(0)).unwrap(), + Complex64::new(-1.0, 0.0), + ); + assert_close( + one.pauli_expectation(&(-Z(0))).unwrap(), + Complex64::new(1.0, 0.0), + ); + assert_close( + one.pauli_expectation(&(i * Z(0))).unwrap(), + Complex64::new(0.0, -1.0), + ); + } + + #[test] + fn default_stabilizer_supports_pauli_expectation_access() { + let mut bell = Stabilizer::new(2); + bell.h(&qid(0)).cx(&[(QubitId(0), QubitId(1))]); + + assert_close( + bell.pauli_expectation(&(X(0) & X(1))).unwrap(), + Complex64::new(1.0, 0.0), + ); + assert_close( + bell.pauli_expectation(&(Z(0) & Z(1))).unwrap(), + Complex64::new(1.0, 0.0), + ); + assert_close( + bell.pauli_expectation(&Z(0)).unwrap(), + Complex64::new(0.0, 0.0), + ); + } + + #[test] + fn state_sampling_z_collapses_state_vector() { + let mut state = StateVec::new(1); + state.h(&qid(0)); + + let first = state.sample_z(&qid(0)).unwrap(); + assert_eq!(first.len(), 1); + assert!(!first[0].is_deterministic); + + let second = state.sample_z(&qid(0)).unwrap(); + assert_eq!(second.len(), 1); + assert!(second[0].is_deterministic); + assert_eq!(second[0].outcome, first[0].outcome); + } + + #[test] + fn state_sampling_x_and_y_bases_use_existing_measurement_semantics() { + let mut plus = StateVec::new(1); + plus.h(&qid(0)); + let x_result = plus.sample_x(&qid(0)).unwrap(); + assert!(x_result[0].is_deterministic); + assert!(!x_result[0].outcome); + + let mut plus_i = StateVec::new(1); + plus_i.h(&qid(0)).sz(&qid(0)); + let y_result = plus_i.sample_y(&qid(0)).unwrap(); + assert!(y_result[0].is_deterministic); + assert!(!y_result[0].outcome); + } + + #[test] + fn state_sampling_batch_preserves_requested_qubit_order() { + let mut state = StateVec::new(2); + state.x(&qid(0)); + + let results = state.sample_z(&[QubitId(1), QubitId(0)]).unwrap(); + assert_eq!(results.len(), 2); + assert!(results[0].is_deterministic); + assert!(results[1].is_deterministic); + assert!(!results[0].outcome); + assert!(results[1].outcome); + } + + #[test] + fn state_sampling_is_available_on_density_matrix_and_stabilizers() { + let mut density = DensityMatrix::new(1); + density.x(&qid(0)); + let density_result = density.sample_z(&qid(0)).unwrap(); + assert!(density_result[0].is_deterministic); + assert!(density_result[0].outcome); + + let mut sparse = SparseStab::new(1); + sparse.x(&qid(0)); + let sparse_result = sparse.sample_z(&qid(0)).unwrap(); + assert!(sparse_result[0].is_deterministic); + assert!(sparse_result[0].outcome); + + let mut hybrid = SparseStabHybrid::new(1); + hybrid.x(&qid(0)); + let hybrid_result = hybrid.sample_z(&qid(0)).unwrap(); + assert!(hybrid_result[0].is_deterministic); + assert!(hybrid_result[0].outcome); + + let mut default_stabilizer = Stabilizer::new(1); + default_stabilizer.x(&qid(0)); + let default_result = default_stabilizer.sample_z(&qid(0)).unwrap(); + assert!(default_result[0].is_deterministic); + assert!(default_result[0].outcome); + } + + #[test] + fn state_access_rejects_out_of_range_queries() { + let mut state = StateVec::new(1); + assert!(matches!( + state.amplitude(2).unwrap_err(), + StateAccessError::BasisIndexOutOfRange { .. } + )); + assert!(matches!( + state.pauli_expectation(&X(1)).unwrap_err(), + StateAccessError::PauliQubitOutOfRange { .. } + )); + let sample_result = state.sample_z(&[QubitId(1)]); + assert!(matches!( + sample_result, + Err(StateAccessError::QubitOutOfRange { .. }) + )); + + let mut rho = DensityMatrix::new(1); + assert!(matches!( + rho.density_matrix_element(0, 2).unwrap_err(), + StateAccessError::BasisIndexOutOfRange { .. } + )); + } + + #[test] + fn sparse_stabilizer_to_state_vector_matches_initial_state() { + let sparse = SparseStab::new(2); + let dense = sparse.to_state_vector().unwrap(); + + assert_state_vectors_close( + &dense, + &[ + Complex64::new(1.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + Complex64::new(0.0, 0.0), + ], + ); + } + + #[test] + fn sparse_stabilizer_to_state_vector_preserves_signed_basis_state() { + let mut sparse = SparseStab::new(1); + sparse.x(&qid(0)); + + let dense = sparse.to_state_vector().unwrap(); + assert_state_vectors_close( + &dense, + &[Complex64::new(0.0, 0.0), Complex64::new(1.0, 0.0)], + ); + } + + #[test] + fn sparse_stabilizer_to_state_vector_matches_bell_statevec() { + let mut sparse = SparseStab::new(2); + sparse.h(&qid(0)).cx(&[(QubitId(0), QubitId(1))]); + + let mut state_vec = StateVec::new(2); + state_vec.h(&qid(0)).cx(&[(QubitId(0), QubitId(1))]); + + let dense = sparse.to_state_vector().unwrap(); + assert_state_vectors_close(&dense, &state_vec.state_vector().unwrap()); + } + + #[test] + fn dense_stabilizer_to_state_vector_matches_ghz_statevec() { + let mut dense = DenseStab::new(3); + let mut state_vec = StateVec::new(3); + let pairs = [(QubitId(0), QubitId(1)), (QubitId(0), QubitId(2))]; + dense.h(&qid(0)).cx(&pairs); + state_vec.h(&qid(0)).cx(&pairs); + + let dense_state = dense.to_state_vector().unwrap(); + assert_state_vectors_close(&dense_state, &state_vec.state_vector().unwrap()); + } + + #[test] + fn sparse_stabilizer_to_state_vector_preserves_complex_phase_state() { + let mut sparse = SparseStab::new(1); + sparse.h(&qid(0)).sz(&qid(0)); + + let mut state_vec = StateVec::new(1); + state_vec.h(&qid(0)).sz(&qid(0)); + + let dense = sparse.to_state_vector().unwrap(); + assert_state_vectors_close(&dense, &state_vec.state_vector().unwrap()); + } + + #[test] + fn stabilizer_to_state_vector_is_available_on_hybrid_and_default_wrapper() { + let mut hybrid = SparseStabHybrid::new(2); + hybrid.h(&qid(0)).cx(&[(QubitId(0), QubitId(1))]); + + let mut default_stabilizer = Stabilizer::new(2); + default_stabilizer + .h(&qid(0)) + .cx(&[(QubitId(0), QubitId(1))]); + + let hybrid_dense = hybrid.to_state_vector().unwrap(); + let default_dense = default_stabilizer.to_state_vector().unwrap(); + assert_state_vectors_close(&hybrid_dense, &default_dense); + } +} diff --git a/crates/pecos-simulators/src/state_vec_soa.rs b/crates/pecos-simulators/src/state_vec_soa.rs index 600c744d6..9b6b885fd 100644 --- a/crates/pecos-simulators/src/state_vec_soa.rs +++ b/crates/pecos-simulators/src/state_vec_soa.rs @@ -4442,11 +4442,11 @@ where results } - /// Optimized measure-and-prepare-Z: always prepares |0⟩ state. + /// Optimized measure-and-prepare-Z: measures qubit, then prepares |0⟩. /// - /// This is more efficient than the default implementation because it: - /// 1. Always collapses to |0⟩ regardless of measurement outcome - /// 2. Avoids the conditional X correction step + /// Fuses projection + conditional X into a single pass over the state vector: + /// - outcome 0: zero |1⟩ amplitudes, normalize |0⟩ (already in |0⟩) + /// - outcome 1: move |1⟩ amplitudes to |0⟩ positions with normalization, zero |1⟩ #[inline] fn mpz(&mut self, qubits: &[QubitId]) -> Vec { self.flush(); @@ -4456,28 +4456,37 @@ where let q_idx = q.index(); let step = 1 << q_idx; - // Calculate probability of measuring |1⟩ let prob_one = self.probability_one(q_idx); - - // Sample outcome (for the measurement result) let outcome = self.rng.bernoulli(prob_one); let is_deterministic = !(1e-10..=1.0 - 1e-10).contains(&prob_one); - // Always prepare |0⟩: zero the |1⟩ amplitudes and normalize |0⟩ - let norm_factor = 1.0 / (1.0 - prob_one).sqrt(); + let norm_factor = if outcome { + 1.0 / prob_one.sqrt() + } else { + 1.0 / (1.0 - prob_one).sqrt() + }; if step < 4 { // Scalar path for i in (0..self.real.len()).step_by(step * 2) { - // Normalize |0⟩ states - for j in i..(i + step) { - self.real[j] *= norm_factor; - self.imag[j] *= norm_factor; - } - // Zero |1⟩ states - for j in (i + step)..(i + 2 * step) { - self.real[j] = 0.0; - self.imag[j] = 0.0; + if outcome { + // Move |1⟩ -> |0⟩ with normalization, zero |1⟩ + for j in 0..step { + self.real[i + j] = self.real[i + step + j] * norm_factor; + self.imag[i + j] = self.imag[i + step + j] * norm_factor; + self.real[i + step + j] = 0.0; + self.imag[i + step + j] = 0.0; + } + } else { + // Normalize |0⟩, zero |1⟩ + for j in 0..step { + self.real[i + j] *= norm_factor; + self.imag[i + j] *= norm_factor; + } + for j in (i + step)..(i + 2 * step) { + self.real[j] = 0.0; + self.imag[j] = 0.0; + } } } } else { @@ -4485,23 +4494,38 @@ where let norm_vec = f64x4::splat(norm_factor); for i in (0..self.real.len()).step_by(step * 2) { - // Normalize |0⟩ states - let mut j = i; - while j + 4 <= i + step { - let re = f64x4::from(&self.real[j..j + 4]); - let im = f64x4::from(&self.imag[j..j + 4]); - let scaled_re: [f64; 4] = (norm_vec * re).into(); - let scaled_im: [f64; 4] = (norm_vec * im).into(); - self.real[j..j + 4].copy_from_slice(&scaled_re); - self.imag[j..j + 4].copy_from_slice(&scaled_im); - j += 4; - } - // Zero |1⟩ states - let mut j = i + step; - while j + 4 <= i + 2 * step { - self.real[j..j + 4].copy_from_slice(&[0.0; 4]); - self.imag[j..j + 4].copy_from_slice(&[0.0; 4]); - j += 4; + if outcome { + // Move |1⟩ -> |0⟩ with normalization, zero |1⟩ + let mut j = 0; + while j + 4 <= step { + let re = f64x4::from(&self.real[i + step + j..i + step + j + 4]); + let im = f64x4::from(&self.imag[i + step + j..i + step + j + 4]); + let scaled_re: [f64; 4] = (norm_vec * re).into(); + let scaled_im: [f64; 4] = (norm_vec * im).into(); + self.real[i + j..i + j + 4].copy_from_slice(&scaled_re); + self.imag[i + j..i + j + 4].copy_from_slice(&scaled_im); + self.real[i + step + j..i + step + j + 4].copy_from_slice(&[0.0; 4]); + self.imag[i + step + j..i + step + j + 4].copy_from_slice(&[0.0; 4]); + j += 4; + } + } else { + // Normalize |0⟩, zero |1⟩ + let mut j = i; + while j + 4 <= i + step { + let re = f64x4::from(&self.real[j..j + 4]); + let im = f64x4::from(&self.imag[j..j + 4]); + let scaled_re: [f64; 4] = (norm_vec * re).into(); + let scaled_im: [f64; 4] = (norm_vec * im).into(); + self.real[j..j + 4].copy_from_slice(&scaled_re); + self.imag[j..j + 4].copy_from_slice(&scaled_im); + j += 4; + } + let mut j = i + step; + while j + 4 <= i + 2 * step { + self.real[j..j + 4].copy_from_slice(&[0.0; 4]); + self.imag[j..j + 4].copy_from_slice(&[0.0; 4]); + j += 4; + } } } } diff --git a/crates/pecos-simulators/src/state_vector_test_utils.rs b/crates/pecos-simulators/src/state_vector_test_utils.rs index ba3009076..8eb17590c 100644 --- a/crates/pecos-simulators/src/state_vector_test_utils.rs +++ b/crates/pecos-simulators/src/state_vector_test_utils.rs @@ -831,6 +831,33 @@ pub fn verify_pz(sim: &mut S) { // After pz, qubit should be in |0⟩ let result = sim.mz(&qid(0)); assert!(!result[0].outcome, "pz should prepare |0⟩"); + + // PZ on |1⟩: must not produce NaN (regression test for + // projection-based mpz override that divided by sqrt(1-prob_one) + // where prob_one=1.0) + sim.reset(); + sim.x(&qid(0)); // deterministic |1⟩ + sim.pz(&qid(0)); + let amp0 = sim.get_amplitude(0); + assert!( + !amp0.re.is_nan() && !amp0.im.is_nan(), + "pz on |1⟩ must not produce NaN amplitudes" + ); + assert!( + (amp0.norm() - 1.0).abs() < TOLERANCE, + "After pz on |1⟩, qubit should be in |0⟩ with unit amplitude" + ); + let result = sim.mz(&qid(0)); + assert!(!result[0].outcome, "pz on |1⟩ should prepare |0⟩"); + + // PZ on |0⟩: trivial case, should remain |0⟩ + sim.reset(); + sim.pz(&qid(0)); + let amp0 = sim.get_amplitude(0); + assert!( + (amp0.norm() - 1.0).abs() < TOLERANCE, + "pz on |0⟩ should remain |0⟩" + ); } /// Verify pnz (prepare |1⟩) operation. @@ -877,6 +904,39 @@ pub fn verify_pz_multiple_qubits(sim: &mut S) { assert!(result1[0].outcome, "qubit 1 should still be |1⟩"); } +/// Verify mid-circuit reset pattern: H-CX-MZ-PZ-CX-MZ on a Bell pair. +/// +/// This is the pattern used in QEC syndrome extraction with ancilla resets. +/// The bug it catches: if mpz projects onto |0⟩ without proper measurement +/// sampling + conditional X, subsequent gates after PZ produce wrong results. +pub fn verify_mid_circuit_reset(sim: &mut S) { + if sim.num_qubits() < 2 { + return; + } + + // Run many seeds. After H-CX: Bell state (|00⟩+|11⟩)/sqrt(2). + // MZ(1) collapses to |00⟩ or |11⟩. + // PZ(1) resets q1 to |0⟩: state is |00⟩ or |10⟩. + // CX(0,1) re-entangles: |00⟩->|00⟩ or |10⟩->|11⟩. + // MZ(1): should always equal first measurement. + for seed in 0..100 { + let mut s = S::with_seed(2, seed); + s.h(&qid(0)); + s.cx(&qid2(0, 1)); + let r1 = s.mz(&qid(1)); + s.pz(&qid(1)); + s.cx(&qid2(0, 1)); + let r2 = s.mz(&qid(1)); + assert_eq!( + r1[0].outcome, + r2[0].outcome, + "seed {seed}: mid-circuit reset: r1={}, r2={} — should match", + u8::from(r1[0].outcome), + u8::from(r2[0].outcome) + ); + } +} + /// Verify measurement consistency - measuring the same qubit multiple times. pub fn verify_measurement_consistency(sim: &mut S) { // After a measurement, repeated measurements should give the same result @@ -2627,6 +2687,7 @@ pub fn run_measurement_test_suite(sim: &mut S) { verify_pz(sim); verify_pnz(sim); verify_pz_multiple_qubits(sim); + verify_mid_circuit_reset(sim); verify_measurement_consistency(sim); verify_mz_detailed(sim); } diff --git a/crates/pecos-simulators/src/symbolic_sparse_stab.rs b/crates/pecos-simulators/src/symbolic_sparse_stab.rs index 9ca67d26c..36d50d0c5 100644 --- a/crates/pecos-simulators/src/symbolic_sparse_stab.rs +++ b/crates/pecos-simulators/src/symbolic_sparse_stab.rs @@ -591,6 +591,139 @@ impl SymbolicSparseStabVecSet { .collect() } + /// Prepare qubit in |0⟩ (reset). Does not record a measurement. + /// + /// Physically: measure Z, discard the outcome, conditionally apply X + /// to force the +1 eigenvalue. + /// + /// Symbolically: project qubit onto +Z eigenstate with empty sign + /// (no measurement dependencies). If the qubit is already in a Z + /// eigenstate, H rotates it to the X basis first so the non-deterministic + /// projection path can properly disentangle it from all other qubits. + pub fn pz(&mut self, q: usize) -> &mut Self { + if self.stabs.col_x[q].is_empty() { + // Qubit is in a Z eigenstate. Rotate to X basis so the + // non-deterministic projection correctly disentangles it + // and transfers sign information through the stabilizer group. + self.h(&[q]); + } + self.pz_nondeterministic(q); + self + } + + /// Project qubit onto +Z eigenstate without recording a measurement. + /// + /// Same Gaussian elimination as `nondeterministic_meas` but does not + /// record a measurement or increment the counter. The resulting `Z` on `q` + /// stabilizer gets an empty sign (eigenvalue +1). + fn pz_nondeterministic(&mut self, q: usize) { + let mut anticom_stabs_col = self.stabs.col_x[q].clone(); + let mut anticom_destabs_col = self.destabs.col_x[q].clone(); + + // Find stabilizer to replace (smallest weight) + let mut smallest_wt = 2 * self.num_qubits + 2; + let mut removed_id: Option = None; + + for stab_id in &anticom_stabs_col { + let weight = self.stabs.row_x[*stab_id].len() + self.stabs.row_z[*stab_id].len(); + if weight < smallest_wt { + smallest_wt = weight; + removed_id = Some(*stab_id); + } + } + + let id = removed_id.expect("col_x[q] was non-empty"); + anticom_stabs_col.remove(&id); + let removed_row_x = self.stabs.row_x[id].clone(); + let removed_row_z = self.stabs.row_z[id].clone(); + + // Phase tracking for anticommuting stabilizers + if self.stabs.signs_minus.contains(&id) { + self.stabs.signs_minus ^= &anticom_stabs_col; + } + if self.stabs.signs_i.contains(&id) { + self.stabs.signs_i.remove(&id); + let gens_common: Vec<_> = self + .stabs + .signs_i + .intersection(&anticom_stabs_col) + .copied() + .collect(); + let gens_only_stabs: Vec<_> = anticom_stabs_col + .difference(&self.stabs.signs_i) + .copied() + .collect(); + for i in gens_common { + self.stabs.signs_minus ^= &i; + self.stabs.signs_i.remove(&i); + } + for i in gens_only_stabs { + self.stabs.signs_i.insert(i); + } + } + + // Multiply all other anticommuting stabilizers by the removed one + let removed_sign = self.stabs.signs[id].clone(); + for g in &anticom_stabs_col { + self.stabs.signs[*g].multiply_assign(&removed_sign); + let num_minuses = removed_row_z.intersection(&self.stabs.row_x[*g]).count(); + if num_minuses & 1 != 0 { + self.stabs.signs_minus ^= g; + } + self.stabs.row_x[*g] ^= &removed_row_x; + self.stabs.row_z[*g] ^= &removed_row_z; + } + + // Update column storage for stabilizers + for i in &removed_row_x { + self.stabs.col_x[*i] ^= &anticom_stabs_col; + } + for i in &removed_row_z { + self.stabs.col_z[*i] ^= &anticom_stabs_col; + } + + // Remove old stabilizer + for i in &self.stabs.row_x[id] { + self.stabs.col_x[*i].remove(&id); + } + for i in &self.stabs.row_z[id] { + self.stabs.col_z[*i].remove(&id); + } + + // Replace with Z_q, sign = empty (forced +1 eigenvalue) + self.stabs.col_z[q].insert(id); + self.stabs.row_x[id].clear(); + self.stabs.row_z[id].clear(); + self.stabs.row_z[id].insert(q); + self.stabs.signs[id] = SymbolicSign::empty(); + self.stabs.signs_minus.remove(&id); + self.stabs.signs_i.remove(&id); + + // Update destabilizers + for i in &self.destabs.row_x[id] { + self.destabs.col_x[*i].remove(&id); + } + for i in &self.destabs.row_z[id] { + self.destabs.col_z[*i].remove(&id); + } + + anticom_destabs_col.remove(&id); + for i in &removed_row_x { + self.destabs.col_x[*i].insert(id); + self.destabs.col_x[*i] ^= &anticom_destabs_col; + } + for i in &removed_row_z { + self.destabs.col_z[*i].insert(id); + self.destabs.col_z[*i] ^= &anticom_destabs_col; + } + for row in &anticom_destabs_col { + self.destabs.row_x[*row] ^= &removed_row_x; + self.destabs.row_z[*row] ^= &removed_row_z; + } + self.destabs.row_x[id] = removed_row_x; + self.destabs.row_z[id] = removed_row_z; + } + /// Handle a deterministic measurement. /// The outcome is determined by combining: /// 1. XOR of measurement dependencies from contributing stabilizers @@ -1283,4 +1416,90 @@ mod tests { assert_eq!(history.format_deterministic(), "[m0=1, m1=0]"); assert_eq!(history.format_nondeterministic(), "[]"); } + + /// Two-round X-stabilizer check: m1 must depend on m0 across reset. + #[test] + fn test_pz_two_round_x_check() { + use crate::measurement_sampler::MeasurementKind; + let mut sim = SymbolicSparseStabVecSet::new(3); + + // Round 1: measure X₁X₂ via ancilla q0 + sim.h(&[0]).cx(&[(0, 1)]).cx(&[(0, 2)]).h(&[0]); + let r0 = sim.mz(&[0]); + assert!(!r0[0].is_deterministic, "m0 should be non-det"); + + sim.pz(0); + + // Round 2: same stabilizer + sim.h(&[0]).cx(&[(0, 1)]).cx(&[(0, 2)]).h(&[0]); + let r1 = sim.mz(&[0]); + + assert!(r1[0].is_deterministic, "m1 should be det, got: {}", r1[0]); + assert_eq!(format!("{}", r1[0]), "m1=m0"); + + let kinds = MeasurementKind::from_history(sim.measurement_history()); + assert!(matches!(kinds[0], MeasurementKind::Random)); + assert!(matches!(kinds[1], MeasurementKind::Copy(0))); + } + + /// Multi-round X-check: correlations must survive 6 reset cycles. + #[test] + fn test_pz_six_round_x_check() { + use crate::measurement_sampler::MeasurementKind; + let mut sim = SymbolicSparseStabVecSet::new(3); + + for _ in 0..6 { + sim.h(&[0]).cx(&[(0, 1)]).cx(&[(0, 2)]).h(&[0]); + sim.mz(&[0]); + sim.pz(0); + } + + let kinds = MeasurementKind::from_history(sim.measurement_history()); + assert_eq!(kinds.len(), 6); + assert!( + matches!(kinds[0], MeasurementKind::Random), + "m0: {:?}", + kinds[0] + ); + // All subsequent measurements must depend on a prior measurement + for (i, k) in kinds.iter().enumerate().skip(1) { + assert!( + matches!(k, MeasurementKind::Copy(_)), + "m{i} should be Copy, got {k:?}" + ); + } + } + + /// After PZ, the reset qubit itself must be fresh (deterministic |0⟩). + #[test] + fn test_pz_reset_qubit_is_fresh() { + let mut sim = SymbolicSparseStabVecSet::new(2); + + // Entangle q0 and q1 via Bell state + sim.h(&[0]).cx(&[(0, 1)]); + // Measure q0 (non-det) + let r = sim.mz(&[0]); + assert!(!r[0].is_deterministic); + + // Reset q0 + sim.pz(0); + + // Measuring q0 again must give deterministic 0 (fresh |0⟩) + let r2 = sim.mz(&[0]); + assert!(r2[0].is_deterministic, "reset qubit should be det"); + assert_eq!(format!("{}", r2[0]), "m1=0", "reset qubit should measure 0"); + } + + /// PZ on a qubit that was never entangled should be a no-op. + #[test] + fn test_pz_on_fresh_qubit() { + let mut sim = SymbolicSparseStabVecSet::new(2); + + // PZ on |0⟩ should not change anything + sim.pz(0); + + let r = sim.mz(&[0]); + assert!(r[0].is_deterministic); + assert_eq!(format!("{}", r[0]), "m0=0"); + } } diff --git a/crates/pecos-simulators/src/symbolic_sparse_stab_bitset.rs b/crates/pecos-simulators/src/symbolic_sparse_stab_bitset.rs index 76506cd45..9ec1a8f4e 100644 --- a/crates/pecos-simulators/src/symbolic_sparse_stab_bitset.rs +++ b/crates/pecos-simulators/src/symbolic_sparse_stab_bitset.rs @@ -51,6 +51,81 @@ pub struct SymbolicSparseStab { measurement_history: MeasurementHistory, } +fn xor_intersection_into(a: &BitSet, b: &BitSet, target: &mut BitSet) { + for i in a { + if b.contains(i) { + target.toggle(i); + } + } +} + +fn mul_i_for(signs_minus: &mut BitSet, signs_i: &mut BitSet, indices: &BitSet) { + for i in indices { + if signs_i.contains(i) { + signs_minus.toggle(i); + signs_i.remove(i); + } else { + signs_i.insert(i); + } + } +} + +fn mul_minus_i_for(signs_minus: &mut BitSet, signs_i: &mut BitSet, indices: &BitSet) { + *signs_minus ^= indices; + mul_i_for(signs_minus, signs_i, indices); +} + +fn toggle_col_x(gens: &mut SymbolicGensBitSet, q: usize, affected: &BitSet) { + let old = gens.col_x[q].clone(); + gens.col_x[q] ^= affected; + for i in &old { + if !gens.col_x[q].contains(i) { + gens.row_x[i].remove(q); + } + } + for i in &gens.col_x[q] { + if !old.contains(i) { + gens.row_x[i].insert(q); + } + } +} + +fn toggle_col_z(gens: &mut SymbolicGensBitSet, q: usize, affected: &BitSet) { + let old = gens.col_z[q].clone(); + gens.col_z[q] ^= affected; + for i in &old { + if !gens.col_z[q].contains(i) { + gens.row_z[i].remove(q); + } + } + for i in &gens.col_z[q] { + if !old.contains(i) { + gens.row_z[i].insert(q); + } + } +} + +fn swap_xz_on(gens: &mut SymbolicGensBitSet, q: usize) { + let only_x: Vec = gens.col_x[q] + .iter() + .filter(|i| !gens.col_z[q].contains(*i)) + .collect(); + let only_z: Vec = gens.col_z[q] + .iter() + .filter(|i| !gens.col_x[q].contains(*i)) + .collect(); + + for i in only_x { + gens.row_x[i].remove(q); + gens.row_z[i].insert(q); + } + for i in only_z { + gens.row_z[i].remove(q); + gens.row_x[i].insert(q); + } + mem::swap(&mut gens.col_x[q], &mut gens.col_z[q]); +} + impl SymbolicSparseStab { /// Create a new BitSet-based symbolic stabilizer simulator. #[inline] @@ -238,6 +313,99 @@ impl SymbolicSparseStab { self } + /// Adjoint sqrt of Z gate. X -> -Y, Z -> Z. + #[inline] + pub fn szdg(&mut self, qubits: &[usize]) -> &mut Self { + for &q in qubits { + let affected = self.stabs.col_x[q].clone(); + mul_minus_i_for( + &mut self.stabs.signs_minus, + &mut self.stabs.signs_i, + &affected, + ); + + for gens in [&mut self.stabs, &mut self.destabs] { + let affected = gens.col_x[q].clone(); + toggle_col_z(gens, q, &affected); + } + } + self + } + + /// Sqrt of X gate. X -> X, Z -> -Y. + #[inline] + pub fn sx(&mut self, qubits: &[usize]) -> &mut Self { + for &q in qubits { + let affected = self.stabs.col_z[q].clone(); + mul_minus_i_for( + &mut self.stabs.signs_minus, + &mut self.stabs.signs_i, + &affected, + ); + + for gens in [&mut self.stabs, &mut self.destabs] { + let affected = gens.col_z[q].clone(); + toggle_col_x(gens, q, &affected); + } + } + self + } + + /// Adjoint sqrt of X gate. X -> X, Z -> Y. + #[inline] + pub fn sxdg(&mut self, qubits: &[usize]) -> &mut Self { + for &q in qubits { + let affected = self.stabs.col_z[q].clone(); + mul_i_for( + &mut self.stabs.signs_minus, + &mut self.stabs.signs_i, + &affected, + ); + + for gens in [&mut self.stabs, &mut self.destabs] { + let affected = gens.col_z[q].clone(); + toggle_col_x(gens, q, &affected); + } + } + self + } + + /// Sqrt of Y gate. X -> -Z, Z -> X. + #[inline] + pub fn sy(&mut self, qubits: &[usize]) -> &mut Self { + for &q in qubits { + self.stabs.signs_minus ^= &self.stabs.col_x[q]; + xor_intersection_into( + &self.stabs.col_x[q], + &self.stabs.col_z[q], + &mut self.stabs.signs_minus, + ); + + for gens in [&mut self.stabs, &mut self.destabs] { + swap_xz_on(gens, q); + } + } + self + } + + /// Adjoint sqrt of Y gate. X -> Z, Z -> -X. + #[inline] + pub fn sydg(&mut self, qubits: &[usize]) -> &mut Self { + for &q in qubits { + self.stabs.signs_minus ^= &self.stabs.col_z[q]; + xor_intersection_into( + &self.stabs.col_x[q], + &self.stabs.col_z[q], + &mut self.stabs.signs_minus, + ); + + for gens in [&mut self.stabs, &mut self.destabs] { + swap_xz_on(gens, q); + } + } + self + } + /// CNOT gate. IX -> IX, XI -> XX, IZ -> ZZ, ZI -> ZI #[inline] pub fn cx(&mut self, pairs: &[(usize, usize)]) -> &mut Self { @@ -288,6 +456,256 @@ impl SymbolicSparseStab { self } + /// Controlled-Y gate. XI -> XY, IX -> ZX, ZI -> ZI, IZ -> ZZ. + #[inline] + pub fn cy(&mut self, pairs: &[(usize, usize)]) -> &mut Self { + for &(q1, q2) in pairs { + // Direct Pauli action, including the target-Y phase for XI. + let affected = self.stabs.col_x[q1].clone(); + let mut both_x = BitSet::new(); + for g in &self.stabs.col_x[q1] { + if self.stabs.col_x[q2].contains(g) { + both_x.insert(g); + } + } + self.stabs.signs_minus ^= &both_x; + mul_i_for( + &mut self.stabs.signs_minus, + &mut self.stabs.signs_i, + &affected, + ); + + for gens in [&mut self.stabs, &mut self.destabs] { + let x1 = gens.col_x[q1].clone(); + let x2 = gens.col_x[q2].clone(); + let z2 = gens.col_z[q2].clone(); + toggle_col_x(gens, q2, &x1); + toggle_col_z(gens, q2, &x1); + + let mut z1_effect = x2; + z1_effect ^= &z2; + toggle_col_z(gens, q1, &z1_effect); + } + } + self + } + + /// Controlled-Z gate. XI -> XZ, IX -> ZX, ZI -> ZI, IZ -> IZ. + #[inline] + pub fn cz(&mut self, pairs: &[(usize, usize)]) -> &mut Self { + for &(q1, q2) in pairs { + xor_intersection_into( + &self.stabs.col_x[q1], + &self.stabs.col_x[q2], + &mut self.stabs.signs_minus, + ); + + for gens in [&mut self.stabs, &mut self.destabs] { + let x1 = gens.col_x[q1].clone(); + let x2 = gens.col_x[q2].clone(); + toggle_col_z(gens, q2, &x1); + toggle_col_z(gens, q1, &x2); + } + } + self + } + + /// Square root of XX gate. XI -> XI, IX -> IX, ZI -> -YX, IZ -> -XY. + #[inline] + pub fn sxx(&mut self, pairs: &[(usize, usize)]) -> &mut Self { + for &(q1, q2) in pairs { + let mut affected = self.stabs.col_z[q1].clone(); + affected ^= &self.stabs.col_z[q2]; + mul_minus_i_for( + &mut self.stabs.signs_minus, + &mut self.stabs.signs_i, + &affected, + ); + + for gens in [&mut self.stabs, &mut self.destabs] { + let mut affected = gens.col_z[q1].clone(); + affected ^= &gens.col_z[q2]; + toggle_col_x(gens, q1, &affected); + toggle_col_x(gens, q2, &affected); + } + } + self + } + + /// Adjoint square root of XX gate. XI -> XI, IX -> IX, ZI -> YX, IZ -> XY. + #[inline] + pub fn sxxdg(&mut self, pairs: &[(usize, usize)]) -> &mut Self { + for &(q1, q2) in pairs { + let mut affected = self.stabs.col_z[q1].clone(); + affected ^= &self.stabs.col_z[q2]; + mul_i_for( + &mut self.stabs.signs_minus, + &mut self.stabs.signs_i, + &affected, + ); + + for gens in [&mut self.stabs, &mut self.destabs] { + let mut affected = gens.col_z[q1].clone(); + affected ^= &gens.col_z[q2]; + toggle_col_x(gens, q1, &affected); + toggle_col_x(gens, q2, &affected); + } + } + self + } + + /// Square root of ZZ gate. XI -> YZ, IX -> ZY, ZI -> ZI, IZ -> IZ. + #[inline] + pub fn szz(&mut self, pairs: &[(usize, usize)]) -> &mut Self { + for &(q1, q2) in pairs { + let mut affected = self.stabs.col_x[q1].clone(); + affected ^= &self.stabs.col_x[q2]; + mul_i_for( + &mut self.stabs.signs_minus, + &mut self.stabs.signs_i, + &affected, + ); + + for gens in [&mut self.stabs, &mut self.destabs] { + let mut affected = gens.col_x[q1].clone(); + affected ^= &gens.col_x[q2]; + toggle_col_z(gens, q1, &affected); + toggle_col_z(gens, q2, &affected); + } + } + self + } + + /// Adjoint square root of ZZ gate. XI -> -YZ, IX -> -ZY, ZI -> ZI, IZ -> IZ. + #[inline] + pub fn szzdg(&mut self, pairs: &[(usize, usize)]) -> &mut Self { + for &(q1, q2) in pairs { + let mut affected = self.stabs.col_x[q1].clone(); + affected ^= &self.stabs.col_x[q2]; + mul_minus_i_for( + &mut self.stabs.signs_minus, + &mut self.stabs.signs_i, + &affected, + ); + + for gens in [&mut self.stabs, &mut self.destabs] { + let mut affected = gens.col_x[q1].clone(); + affected ^= &gens.col_x[q2]; + toggle_col_z(gens, q1, &affected); + toggle_col_z(gens, q2, &affected); + } + } + self + } + + /// Square root of YY gate. + /// + /// XI -> -ZY, IX -> -YZ, ZI -> XY, IZ -> YX. + #[inline] + pub fn syy(&mut self, pairs: &[(usize, usize)]) -> &mut Self { + for &(q1, q2) in pairs { + self.apply_syy_signs(q1, q2, false); + self.apply_syy_bits(q1, q2); + } + self + } + + /// Adjoint square root of YY gate. + /// + /// XI -> ZY, IX -> YZ, ZI -> -XY, IZ -> -YX. + #[inline] + pub fn syydg(&mut self, pairs: &[(usize, usize)]) -> &mut Self { + for &(q1, q2) in pairs { + self.apply_syy_signs(q1, q2, true); + self.apply_syy_bits(q1, q2); + } + self + } + + /// SWAP gate. XI -> IX, IX -> XI, ZI -> IZ, IZ -> ZI. + #[inline] + pub fn swap(&mut self, pairs: &[(usize, usize)]) -> &mut Self { + for &(q1, q2) in pairs { + for gens in [&mut self.stabs, &mut self.destabs] { + let mut affected_x = gens.col_x[q1].clone(); + affected_x ^= &gens.col_x[q2]; + toggle_col_x(gens, q1, &affected_x); + toggle_col_x(gens, q2, &affected_x); + + let mut affected_z = gens.col_z[q1].clone(); + affected_z ^= &gens.col_z[q2]; + toggle_col_z(gens, q1, &affected_z); + toggle_col_z(gens, q2, &affected_z); + } + } + self + } + + fn apply_syy_bits(&mut self, q1: usize, q2: usize) { + for gens in [&mut self.stabs, &mut self.destabs] { + let mut affected = gens.col_x[q1].clone(); + affected ^= &gens.col_z[q1]; + affected ^= &gens.col_x[q2]; + affected ^= &gens.col_z[q2]; + toggle_col_x(gens, q1, &affected); + toggle_col_x(gens, q2, &affected); + toggle_col_z(gens, q1, &affected); + toggle_col_z(gens, q2, &affected); + } + } + + fn apply_syy_signs(&mut self, q1: usize, q2: usize, adjoint: bool) { + let col_x = &self.stabs.col_x; + let col_z = &self.stabs.col_z; + let signs_minus = &mut self.stabs.signs_minus; + let signs_i = &mut self.stabs.signs_i; + + macro_rules! apply_syy_sign { + ($g:expr, $x1:expr, $z1:expr, $x2:expr, $z2:expr) => { + if ($x1 != $z1) != ($x2 != $z2) { + let use_plus_i = ($z1 != $z2) != adjoint; + if use_plus_i { + mul_i_for(signs_minus, signs_i, &BitSet::single($g)); + } else { + mul_minus_i_for(signs_minus, signs_i, &BitSet::single($g)); + } + } + }; + } + + for g in &col_x[q1] { + let x1 = true; + let z1 = col_z[q1].contains(g); + let x2 = col_x[q2].contains(g); + let z2 = col_z[q2].contains(g); + apply_syy_sign!(g, x1, z1, x2, z2); + } + for g in &col_z[q1] { + if col_x[q1].contains(g) { + continue; + } + let x1 = false; + let z1 = true; + let x2 = col_x[q2].contains(g); + let z2 = col_z[q2].contains(g); + apply_syy_sign!(g, x1, z1, x2, z2); + } + for g in &col_x[q2] { + if col_x[q1].contains(g) || col_z[q1].contains(g) { + continue; + } + let x2 = true; + let z2 = col_z[q2].contains(g); + apply_syy_sign!(g, false, false, x2, z2); + } + for g in &col_z[q2] { + if col_x[q1].contains(g) || col_z[q1].contains(g) || col_x[q2].contains(g) { + continue; + } + apply_syy_sign!(g, false, false, false, true); + } + } + // ==================== Measurement ==================== /// Measure qubits in the Z basis. @@ -308,6 +726,129 @@ impl SymbolicSparseStab { .collect() } + /// Prepare qubit in |0⟩ (reset). Does not record a measurement. + /// + /// See `SymbolicSparseStabVecSet::pz` for detailed documentation. + pub fn pz(&mut self, q: usize) -> &mut Self { + if self.stabs.col_x[q].is_empty() { + self.h(&[q]); + } + self.pz_nondeterministic(q); + self + } + + /// Project qubit onto +Z eigenstate without recording a measurement. + /// + /// Same Gaussian elimination as `nondeterministic_meas` but with + /// empty sign and no measurement counter increment. + fn pz_nondeterministic(&mut self, q: usize) { + let mut anticom_stabs_col = self.stabs.col_x[q].clone(); + let mut anticom_destabs_col = self.destabs.col_x[q].clone(); + + let mut smallest_wt = 2 * self.num_qubits + 2; + let mut removed_id: Option = None; + + for stab_id in &anticom_stabs_col { + let weight = self.stabs.row_x[stab_id].len() + self.stabs.row_z[stab_id].len(); + if weight < smallest_wt { + smallest_wt = weight; + removed_id = Some(stab_id); + } + } + + let id = removed_id.expect("col_x[q] was non-empty"); + anticom_stabs_col.remove(id); + let removed_row_x = self.stabs.row_x[id].clone(); + let removed_row_z = self.stabs.row_z[id].clone(); + + if self.stabs.signs_minus.contains(id) { + self.stabs.signs_minus ^= &anticom_stabs_col; + } + if self.stabs.signs_i.contains(id) { + self.stabs.signs_i.remove(id); + let gens_common: Vec = self + .stabs + .signs_i + .iter() + .filter(|i| anticom_stabs_col.contains(*i)) + .collect(); + let gens_only_stabs: Vec = anticom_stabs_col + .iter() + .filter(|i| !self.stabs.signs_i.contains(*i)) + .collect(); + for i in gens_common { + let i_set = BitSet::single(i); + self.stabs.signs_minus ^= &i_set; + self.stabs.signs_i.remove(i); + } + for i in gens_only_stabs { + self.stabs.signs_i.insert(i); + } + } + + let removed_sign = self.stabs.signs[id].clone(); + for g in &anticom_stabs_col { + self.stabs.signs[g].multiply_assign(&removed_sign); + let mut num_minuses = 0; + for z in &removed_row_z { + if self.stabs.row_x[g].contains(z) { + num_minuses += 1; + } + } + if num_minuses & 1 != 0 { + let g_set = BitSet::single(g); + self.stabs.signs_minus ^= &g_set; + } + self.stabs.row_x[g] ^= &removed_row_x; + self.stabs.row_z[g] ^= &removed_row_z; + } + + for i in &removed_row_x { + self.stabs.col_x[i] ^= &anticom_stabs_col; + } + for i in &removed_row_z { + self.stabs.col_z[i] ^= &anticom_stabs_col; + } + + for i in &self.stabs.row_x[id] { + self.stabs.col_x[i].remove(id); + } + for i in &self.stabs.row_z[id] { + self.stabs.col_z[i].remove(id); + } + + self.stabs.col_z[q].insert(id); + self.stabs.row_x[id].clear(); + self.stabs.row_z[id].clear(); + self.stabs.row_z[id].insert(q); + self.stabs.signs[id] = SymbolicSign::empty(); + self.stabs.signs_minus.remove(id); + self.stabs.signs_i.remove(id); + + for i in &self.destabs.row_x[id] { + self.destabs.col_x[i].remove(id); + } + for i in &self.destabs.row_z[id] { + self.destabs.col_z[i].remove(id); + } + + anticom_destabs_col.remove(id); + for i in &removed_row_x { + self.destabs.col_x[i].insert(id); + self.destabs.col_x[i] ^= &anticom_destabs_col; + } + for i in &removed_row_z { + self.destabs.col_z[i].insert(id); + self.destabs.col_z[i] ^= &anticom_destabs_col; + } + for row in &anticom_destabs_col { + self.destabs.row_x[row] ^= &removed_row_x; + self.destabs.row_z[row] ^= &removed_row_z; + } + self.destabs.row_x[id] = removed_row_x; + self.destabs.row_z[id] = removed_row_z; + } + /// Handle a deterministic measurement. fn deterministic_meas(&mut self, q: usize) -> SymbolicMeasurementResult { let index = self.measurement_counter; @@ -516,6 +1057,322 @@ impl QuantumSimulator for SymbolicSparseStab { #[cfg(test)] mod tests { use super::*; + use crate::clifford_matrix_oracle::{ + CliffordMatrixGate, SignedPauli, all_pauli_strings, conjugate_pauli, + }; + + fn assert_same_gens(left: &SymbolicGensBitSet, right: &SymbolicGensBitSet) { + assert_eq!(left.col_x, right.col_x); + assert_eq!(left.col_z, right.col_z); + assert_eq!(left.row_x, right.row_x); + assert_eq!(left.row_z, right.row_z); + assert_eq!(left.signs, right.signs); + assert_eq!(left.signs_minus, right.signs_minus); + assert_eq!(left.signs_i, right.signs_i); + } + + fn assert_same_state(left: &SymbolicSparseStab, right: &SymbolicSparseStab) { + assert_same_gens(&left.stabs, &right.stabs); + assert_same_gens(&left.destabs, &right.destabs); + assert_eq!(left.measurement_counter, right.measurement_counter); + assert_eq!( + left.measurement_history.as_slice(), + right.measurement_history.as_slice() + ); + } + + fn nontrivial_state() -> SymbolicSparseStab { + let mut sim = SymbolicSparseStab::new(3); + sim.h(&[0, 1]); + sim.cx(&[(0, 2), (1, 2)]); + sim.sz(&[0, 2]); + sim.h(&[2]); + sim + } + + fn check_direct_gate( + apply_direct: impl FnOnce(&mut SymbolicSparseStab), + apply_reference: impl FnOnce(&mut SymbolicSparseStab), + ) { + let mut direct = nontrivial_state(); + let mut reference = direct.clone(); + apply_direct(&mut direct); + apply_reference(&mut reference); + assert_same_state(&direct, &reference); + } + + fn row_binary(gens: &SymbolicGensBitSet, row: usize, num_qubits: usize) -> String { + let mut dense = String::with_capacity(num_qubits); + for q in 0..num_qubits { + dense.push( + match (gens.row_x[row].contains(q), gens.row_z[row].contains(q)) { + (false, false) => 'I', + (true, false) => 'X', + (false, true) => 'Z', + (true, true) => 'Y', + }, + ); + } + dense + } + + fn assert_symbolic_single_qubit_gate_basis( + apply: impl FnOnce(&mut SymbolicSparseStab), + expected_x: &str, + expected_z: &str, + ) { + let mut sim = SymbolicSparseStab::new(1); + apply(&mut sim); + + assert_eq!(row_binary(&sim.destabs, 0, 1), expected_x, "X image"); + assert_eq!(row_binary(&sim.stabs, 0, 1), expected_z, "Z image"); + } + + fn assert_symbolic_two_qubit_gate_basis( + apply: impl FnOnce(&mut SymbolicSparseStab), + expected_xi: &str, + expected_ix: &str, + expected_zi: &str, + expected_iz: &str, + ) { + let mut sim = SymbolicSparseStab::new(2); + apply(&mut sim); + + assert_eq!(row_binary(&sim.destabs, 0, 2), expected_xi, "XI image"); + assert_eq!(row_binary(&sim.destabs, 1, 2), expected_ix, "IX image"); + assert_eq!(row_binary(&sim.stabs, 0, 2), expected_zi, "ZI image"); + assert_eq!(row_binary(&sim.stabs, 1, 2), expected_iz, "IZ image"); + } + + fn set_stab_row_to_pauli(gens: &mut SymbolicGensBitSet, row: usize, pauli: &str) { + let mut y_count = 0usize; + for (q, label) in pauli.chars().enumerate() { + match label { + 'I' => {} + 'X' => { + gens.row_x[row].insert(q); + gens.col_x[q].insert(row); + } + 'Y' => { + y_count += 1; + gens.row_x[row].insert(q); + gens.row_z[row].insert(q); + gens.col_x[q].insert(row); + gens.col_z[q].insert(row); + } + 'Z' => { + gens.row_z[row].insert(q); + gens.col_z[q].insert(row); + } + _ => panic!("invalid Pauli label {label}"), + } + } + + match y_count % 4 { + 0 => {} + 1 => { + gens.signs_i.insert(row); + } + 2 => { + gens.signs_minus.insert(row); + } + 3 => { + gens.signs_minus.insert(row); + gens.signs_i.insert(row); + } + _ => unreachable!(), + } + } + + fn signed_stab_row(gens: &SymbolicGensBitSet, row: usize, num_qubits: usize) -> SignedPauli { + assert!( + gens.signs[row].measurements.is_empty(), + "unexpected measurement-dependent sign" + ); + let pauli = row_binary(gens, row, num_qubits); + let y_count = pauli.chars().filter(|&label| label == 'Y').count(); + let internal_phase = usize::from(gens.signs_i.contains(row)) + + if gens.signs_minus.contains(row) { 2 } else { 0 }; + let canonical_phase = (internal_phase + 3 * y_count) % 4; + assert!( + canonical_phase == 0 || canonical_phase == 2, + "unexpected non-Hermitian phase i^{canonical_phase} for row {row}" + ); + SignedPauli { + sign: if canonical_phase == 2 { -1 } else { 1 }, + pauli, + } + } + + fn symbolic_image_for_pauli(num_qubits: usize, input: &str, apply: F) -> SignedPauli + where + F: FnOnce(&mut SymbolicSparseStab), + { + let mut sim = SymbolicSparseStab::new(num_qubits); + sim.stabs = SymbolicGensBitSet::new(num_qubits); + sim.destabs = SymbolicGensBitSet::new(num_qubits); + set_stab_row_to_pauli(&mut sim.stabs, 0, input); + apply(&mut sim); + signed_stab_row(&sim.stabs, 0, num_qubits) + } + + fn assert_symbolic_gate_matches_matrix_oracle( + name: &str, + gate: CliffordMatrixGate, + num_qubits: usize, + apply: F, + ) where + F: Fn(&mut SymbolicSparseStab) + Copy, + { + for input in all_pauli_strings(num_qubits) { + let expected = conjugate_pauli(gate, &input); + let observed = symbolic_image_for_pauli(num_qubits, &input, apply); + assert_eq!(observed, expected, "{name}: {input}"); + } + } + + fn reverse_two_qubit_pauli(pauli: &str) -> String { + let labels: Vec = pauli.chars().collect(); + assert_eq!(labels.len(), 2); + [labels[1], labels[0]].into_iter().collect() + } + + fn assert_symbolic_reversed_pair_matches_matrix_oracle( + name: &str, + gate: CliffordMatrixGate, + apply: F, + ) where + F: Fn(&mut SymbolicSparseStab, &[(usize, usize)]) + Copy, + { + for input in all_pauli_strings(2) { + let oracle_input = reverse_two_qubit_pauli(&input); + let mut expected = conjugate_pauli(gate, &oracle_input); + expected.pauli = reverse_two_qubit_pauli(&expected.pauli); + + let observed = symbolic_image_for_pauli(2, &input, |sim| { + apply(sim, &[(1, 0)]); + }); + assert_eq!(observed, expected, "{name} reversed pair: {input}"); + } + } + + fn assert_symbolic_two_pair_batch_matches_sequential(name: &str, apply: F) + where + F: Fn(&mut SymbolicSparseStab, &[(usize, usize)]) + Copy, + { + for input in all_pauli_strings(4) { + let batched = symbolic_image_for_pauli(4, &input, |sim| { + apply(sim, &[(0, 1), (2, 3)]); + }); + let sequential = symbolic_image_for_pauli(4, &input, |sim| { + apply(sim, &[(0, 1)]); + apply(sim, &[(2, 3)]); + }); + assert_eq!(batched, sequential, "{name} batched: {input}"); + } + } + + fn ref_szdg(sim: &mut SymbolicSparseStab, qs: &[usize]) { + sim.z(qs); + sim.sz(qs); + } + + fn ref_sx(sim: &mut SymbolicSparseStab, qs: &[usize]) { + sim.h(qs); + sim.sz(qs); + sim.h(qs); + } + + fn ref_sxdg(sim: &mut SymbolicSparseStab, qs: &[usize]) { + sim.h(qs); + ref_szdg(sim, qs); + sim.h(qs); + } + + fn ref_sy(sim: &mut SymbolicSparseStab, qs: &[usize]) { + sim.z(qs); + sim.h(qs); + } + + fn ref_sydg(sim: &mut SymbolicSparseStab, qs: &[usize]) { + sim.h(qs); + sim.z(qs); + } + + fn ref_cy(sim: &mut SymbolicSparseStab, pairs: &[(usize, usize)]) { + let targets: Vec = pairs.iter().map(|&(_, q2)| q2).collect(); + ref_szdg(sim, &targets); + sim.cx(pairs); + sim.sz(&targets); + } + + fn ref_cz(sim: &mut SymbolicSparseStab, pairs: &[(usize, usize)]) { + let targets: Vec = pairs.iter().map(|&(_, q2)| q2).collect(); + sim.h(&targets); + sim.cx(pairs); + sim.h(&targets); + } + + fn ref_sxx(sim: &mut SymbolicSparseStab, pairs: &[(usize, usize)]) { + let q1s: Vec = pairs.iter().map(|&(q1, _)| q1).collect(); + let q2s: Vec = pairs.iter().map(|&(_, q2)| q2).collect(); + ref_sx(sim, &q1s); + ref_sx(sim, &q2s); + ref_sydg(sim, &q1s); + sim.cx(pairs); + ref_sy(sim, &q1s); + } + + fn ref_sxxdg(sim: &mut SymbolicSparseStab, pairs: &[(usize, usize)]) { + let q1s: Vec = pairs.iter().map(|&(q1, _)| q1).collect(); + let q2s: Vec = pairs.iter().map(|&(_, q2)| q2).collect(); + sim.x(&q1s); + sim.x(&q2s); + ref_sxx(sim, pairs); + } + + fn ref_syy(sim: &mut SymbolicSparseStab, pairs: &[(usize, usize)]) { + let q1s: Vec = pairs.iter().map(|&(q1, _)| q1).collect(); + let q2s: Vec = pairs.iter().map(|&(_, q2)| q2).collect(); + ref_szdg(sim, &q1s); + ref_szdg(sim, &q2s); + ref_sxx(sim, pairs); + sim.sz(&q1s); + sim.sz(&q2s); + } + + fn ref_syydg(sim: &mut SymbolicSparseStab, pairs: &[(usize, usize)]) { + let q1s: Vec = pairs.iter().map(|&(q1, _)| q1).collect(); + let q2s: Vec = pairs.iter().map(|&(_, q2)| q2).collect(); + sim.y(&q1s); + sim.y(&q2s); + ref_syy(sim, pairs); + } + + fn ref_szz(sim: &mut SymbolicSparseStab, pairs: &[(usize, usize)]) { + let q1s: Vec = pairs.iter().map(|&(q1, _)| q1).collect(); + let q2s: Vec = pairs.iter().map(|&(_, q2)| q2).collect(); + sim.h(&q1s); + sim.h(&q2s); + ref_sxx(sim, pairs); + sim.h(&q1s); + sim.h(&q2s); + } + + fn ref_szzdg(sim: &mut SymbolicSparseStab, pairs: &[(usize, usize)]) { + let q1s: Vec = pairs.iter().map(|&(q1, _)| q1).collect(); + let q2s: Vec = pairs.iter().map(|&(_, q2)| q2).collect(); + sim.z(&q1s); + sim.z(&q2s); + ref_szz(sim, pairs); + } + + fn ref_swap(sim: &mut SymbolicSparseStab, pairs: &[(usize, usize)]) { + let reversed: Vec<(usize, usize)> = pairs.iter().map(|&(q1, q2)| (q2, q1)).collect(); + sim.cx(pairs); + sim.cx(&reversed); + sim.cx(pairs); + } #[test] fn test_bell_state_bitset() { @@ -552,4 +1409,390 @@ mod tests { assert!(r1.outcome.is_empty()); assert_eq!(r1.index, 1); } + + #[test] + fn test_direct_clifford_gate_binary_truth_tables() { + assert_symbolic_single_qubit_gate_basis( + |sim| { + sim.szdg(&[0]); + }, + "Y", + "Z", + ); + assert_symbolic_single_qubit_gate_basis( + |sim| { + sim.sx(&[0]); + }, + "X", + "Y", + ); + assert_symbolic_single_qubit_gate_basis( + |sim| { + sim.sxdg(&[0]); + }, + "X", + "Y", + ); + assert_symbolic_single_qubit_gate_basis( + |sim| { + sim.sy(&[0]); + }, + "Z", + "X", + ); + assert_symbolic_single_qubit_gate_basis( + |sim| { + sim.sydg(&[0]); + }, + "Z", + "X", + ); + assert_symbolic_two_qubit_gate_basis( + |sim| { + sim.cy(&[(0, 1)]); + }, + "XY", + "ZX", + "ZI", + "ZZ", + ); + assert_symbolic_two_qubit_gate_basis( + |sim| { + sim.cz(&[(0, 1)]); + }, + "XZ", + "ZX", + "ZI", + "IZ", + ); + assert_symbolic_two_qubit_gate_basis( + |sim| { + sim.sxx(&[(0, 1)]); + }, + "XI", + "IX", + "YX", + "XY", + ); + assert_symbolic_two_qubit_gate_basis( + |sim| { + sim.sxxdg(&[(0, 1)]); + }, + "XI", + "IX", + "YX", + "XY", + ); + assert_symbolic_two_qubit_gate_basis( + |sim| { + sim.syy(&[(0, 1)]); + }, + "ZY", + "YZ", + "XY", + "YX", + ); + assert_symbolic_two_qubit_gate_basis( + |sim| { + sim.syydg(&[(0, 1)]); + }, + "ZY", + "YZ", + "XY", + "YX", + ); + assert_symbolic_two_qubit_gate_basis( + |sim| { + sim.szz(&[(0, 1)]); + }, + "YZ", + "ZY", + "ZI", + "IZ", + ); + assert_symbolic_two_qubit_gate_basis( + |sim| { + sim.szzdg(&[(0, 1)]); + }, + "YZ", + "ZY", + "ZI", + "IZ", + ); + assert_symbolic_two_qubit_gate_basis( + |sim| { + sim.swap(&[(0, 1)]); + }, + "IX", + "XI", + "IZ", + "ZI", + ); + } + + #[test] + fn test_direct_clifford_gates_match_matrix_oracle_for_all_paulis() { + assert_symbolic_gate_matches_matrix_oracle("CX", CliffordMatrixGate::CX, 2, |sim| { + sim.cx(&[(0, 1)]); + }); + assert_symbolic_gate_matches_matrix_oracle("SZdg", CliffordMatrixGate::SZdg, 1, |sim| { + sim.szdg(&[0]); + }); + assert_symbolic_gate_matches_matrix_oracle("F", CliffordMatrixGate::F, 1, |sim| { + sim.sx(&[0]); + sim.sz(&[0]); + }); + assert_symbolic_gate_matches_matrix_oracle("Fdg", CliffordMatrixGate::Fdg, 1, |sim| { + sim.szdg(&[0]); + sim.sxdg(&[0]); + }); + assert_symbolic_gate_matches_matrix_oracle("SX", CliffordMatrixGate::SX, 1, |sim| { + sim.sx(&[0]); + }); + assert_symbolic_gate_matches_matrix_oracle("SXdg", CliffordMatrixGate::SXdg, 1, |sim| { + sim.sxdg(&[0]); + }); + assert_symbolic_gate_matches_matrix_oracle("SY", CliffordMatrixGate::SY, 1, |sim| { + sim.sy(&[0]); + }); + assert_symbolic_gate_matches_matrix_oracle("SYdg", CliffordMatrixGate::SYdg, 1, |sim| { + sim.sydg(&[0]); + }); + assert_symbolic_gate_matches_matrix_oracle("CY", CliffordMatrixGate::CY, 2, |sim| { + sim.cy(&[(0, 1)]); + }); + assert_symbolic_gate_matches_matrix_oracle("CZ", CliffordMatrixGate::CZ, 2, |sim| { + sim.cz(&[(0, 1)]); + }); + assert_symbolic_gate_matches_matrix_oracle("SXX", CliffordMatrixGate::SXX, 2, |sim| { + sim.sxx(&[(0, 1)]); + }); + assert_symbolic_gate_matches_matrix_oracle("SXXdg", CliffordMatrixGate::SXXdg, 2, |sim| { + sim.sxxdg(&[(0, 1)]); + }); + assert_symbolic_gate_matches_matrix_oracle("SYY", CliffordMatrixGate::SYY, 2, |sim| { + sim.syy(&[(0, 1)]); + }); + assert_symbolic_gate_matches_matrix_oracle("SYYdg", CliffordMatrixGate::SYYdg, 2, |sim| { + sim.syydg(&[(0, 1)]); + }); + assert_symbolic_gate_matches_matrix_oracle("SZZ", CliffordMatrixGate::SZZ, 2, |sim| { + sim.szz(&[(0, 1)]); + }); + assert_symbolic_gate_matches_matrix_oracle("SZZdg", CliffordMatrixGate::SZZdg, 2, |sim| { + sim.szzdg(&[(0, 1)]); + }); + assert_symbolic_gate_matches_matrix_oracle("SWAP", CliffordMatrixGate::SWAP, 2, |sim| { + sim.swap(&[(0, 1)]); + }); + } + + #[test] + fn test_cy_xx_sign_regression() { + assert_eq!( + symbolic_image_for_pauli(2, "XX", |sim| { + sim.cy(&[(0, 1)]); + }), + SignedPauli { + sign: -1, + pauli: "YZ".to_string() + } + ); + } + + #[test] + fn test_two_qubit_gates_reversed_pair_matches_matrix_oracle() { + assert_symbolic_reversed_pair_matches_matrix_oracle( + "CX", + CliffordMatrixGate::CX, + |sim, pairs| { + sim.cx(pairs); + }, + ); + assert_symbolic_reversed_pair_matches_matrix_oracle( + "CY", + CliffordMatrixGate::CY, + |sim, pairs| { + sim.cy(pairs); + }, + ); + assert_symbolic_reversed_pair_matches_matrix_oracle( + "CZ", + CliffordMatrixGate::CZ, + |sim, pairs| { + sim.cz(pairs); + }, + ); + assert_symbolic_reversed_pair_matches_matrix_oracle( + "SXX", + CliffordMatrixGate::SXX, + |sim, pairs| { + sim.sxx(pairs); + }, + ); + assert_symbolic_reversed_pair_matches_matrix_oracle( + "SXXdg", + CliffordMatrixGate::SXXdg, + |sim, pairs| { + sim.sxxdg(pairs); + }, + ); + assert_symbolic_reversed_pair_matches_matrix_oracle( + "SYY", + CliffordMatrixGate::SYY, + |sim, pairs| { + sim.syy(pairs); + }, + ); + assert_symbolic_reversed_pair_matches_matrix_oracle( + "SYYdg", + CliffordMatrixGate::SYYdg, + |sim, pairs| { + sim.syydg(pairs); + }, + ); + assert_symbolic_reversed_pair_matches_matrix_oracle( + "SZZ", + CliffordMatrixGate::SZZ, + |sim, pairs| { + sim.szz(pairs); + }, + ); + assert_symbolic_reversed_pair_matches_matrix_oracle( + "SZZdg", + CliffordMatrixGate::SZZdg, + |sim, pairs| { + sim.szzdg(pairs); + }, + ); + assert_symbolic_reversed_pair_matches_matrix_oracle( + "SWAP", + CliffordMatrixGate::SWAP, + |sim, pairs| { + sim.swap(pairs); + }, + ); + } + + #[test] + fn test_two_qubit_gate_batches_match_sequential_pairs() { + assert_symbolic_two_pair_batch_matches_sequential("CX", |sim, pairs| { + sim.cx(pairs); + }); + assert_symbolic_two_pair_batch_matches_sequential("CY", |sim, pairs| { + sim.cy(pairs); + }); + assert_symbolic_two_pair_batch_matches_sequential("CZ", |sim, pairs| { + sim.cz(pairs); + }); + assert_symbolic_two_pair_batch_matches_sequential("SXX", |sim, pairs| { + sim.sxx(pairs); + }); + assert_symbolic_two_pair_batch_matches_sequential("SXXdg", |sim, pairs| { + sim.sxxdg(pairs); + }); + assert_symbolic_two_pair_batch_matches_sequential("SYY", |sim, pairs| { + sim.syy(pairs); + }); + assert_symbolic_two_pair_batch_matches_sequential("SYYdg", |sim, pairs| { + sim.syydg(pairs); + }); + assert_symbolic_two_pair_batch_matches_sequential("SZZ", |sim, pairs| { + sim.szz(pairs); + }); + assert_symbolic_two_pair_batch_matches_sequential("SZZdg", |sim, pairs| { + sim.szzdg(pairs); + }); + assert_symbolic_two_pair_batch_matches_sequential("SWAP", |sim, pairs| { + sim.swap(pairs); + }); + } + + #[test] + fn test_direct_clifford_gates_match_reference_sequences() { + check_direct_gate( + |sim| { + sim.szdg(&[0]); + }, + |sim| ref_szdg(sim, &[0]), + ); + check_direct_gate( + |sim| { + sim.sx(&[0]); + }, + |sim| ref_sx(sim, &[0]), + ); + check_direct_gate( + |sim| { + sim.sxdg(&[0]); + }, + |sim| ref_sxdg(sim, &[0]), + ); + check_direct_gate( + |sim| { + sim.sy(&[0]); + }, + |sim| ref_sy(sim, &[0]), + ); + check_direct_gate( + |sim| { + sim.sydg(&[0]); + }, + |sim| ref_sydg(sim, &[0]), + ); + check_direct_gate( + |sim| { + sim.cy(&[(0, 1)]); + }, + |sim| ref_cy(sim, &[(0, 1)]), + ); + check_direct_gate( + |sim| { + sim.cz(&[(0, 1)]); + }, + |sim| ref_cz(sim, &[(0, 1)]), + ); + check_direct_gate( + |sim| { + sim.sxx(&[(0, 1)]); + }, + |sim| ref_sxx(sim, &[(0, 1)]), + ); + check_direct_gate( + |sim| { + sim.sxxdg(&[(0, 1)]); + }, + |sim| ref_sxxdg(sim, &[(0, 1)]), + ); + check_direct_gate( + |sim| { + sim.syy(&[(0, 1)]); + }, + |sim| ref_syy(sim, &[(0, 1)]), + ); + check_direct_gate( + |sim| { + sim.syydg(&[(0, 1)]); + }, + |sim| ref_syydg(sim, &[(0, 1)]), + ); + check_direct_gate( + |sim| { + sim.szz(&[(0, 1)]); + }, + |sim| ref_szz(sim, &[(0, 1)]), + ); + check_direct_gate( + |sim| { + sim.szzdg(&[(0, 1)]); + }, + |sim| ref_szzdg(sim, &[(0, 1)]), + ); + check_direct_gate( + |sim| { + sim.swap(&[(0, 1)]); + }, + |sim| ref_swap(sim, &[(0, 1)]), + ); + } } diff --git a/crates/pecos-tesseract/build_tesseract.rs b/crates/pecos-tesseract/build_tesseract.rs index 1c10e3166..9ed16962c 100644 --- a/crates/pecos-tesseract/build_tesseract.rs +++ b/crates/pecos-tesseract/build_tesseract.rs @@ -2,6 +2,7 @@ use pecos_build::{Manifest, Result, check_cxx20_toolchain, ensure_dep_ready, report_cache_config}; use std::env; +use std::fs; use std::path::{Path, PathBuf}; // Use the shared modules from the parent @@ -52,18 +53,19 @@ pub fn build() -> Result<()> { println!("cargo:rustc-link-search=native={}", out_dir.display()); println!("cargo:rustc-link-lib=static=tesseract-bridge"); - // Get Tesseract and Stim sources (downloads to ~/.pecos/cache/, extracts to ~/.pecos/deps/) + // Get Tesseract, Stim, and Boost sources (downloads to ~/.pecos/cache/, extracts to ~/.pecos/deps/) let manifest = Manifest::find_and_load_validated()?; let tesseract_dir = ensure_dep_ready("tesseract", &manifest)?; let stim_dir = ensure_dep_ready("stim", &manifest)?; + let boost_dir = ensure_dep_ready("boost", &manifest)?; // Build using cxx - build_cxx_bridge(&tesseract_dir, &stim_dir); + build_cxx_bridge(&tesseract_dir, &stim_dir, &boost_dir); Ok(()) } -fn build_cxx_bridge(tesseract_dir: &Path, stim_dir: &Path) { +fn build_cxx_bridge(tesseract_dir: &Path, stim_dir: &Path, boost_dir: &Path) { let tesseract_src_dir = tesseract_dir.join("src"); let stim_src_dir = stim_dir.join("src"); @@ -89,11 +91,27 @@ fn build_cxx_bridge(tesseract_dir: &Path, stim_dir: &Path) { .file(tesseract_src_dir.join("utils.cc")) .file(tesseract_src_dir.join("tesseract.cc")); + // visualization.cc uses std::min(3ul, vec.size()). On MSVC Win64, + // unsigned long is 32-bit and size_t is 64-bit, so std::min template + // deduction fails. Patch the source to use size_t{3} instead. + let vis_src = tesseract_src_dir.join("visualization.cc"); + let vis_content = fs::read_to_string(&vis_src).expect("read visualization.cc"); + if vis_content.contains("std::min(3ul,") { + let out_dir = PathBuf::from(env::var("OUT_DIR").unwrap()); + let patched = out_dir.join("visualization_patched.cc"); + let fixed = vis_content.replace("std::min(3ul,", "std::min(size_t{3},"); + fs::write(&patched, fixed).expect("write patched visualization.cc"); + build.file(patched); + } else { + build.file(vis_src); + } + // Configure build build .std("c++20") .include(&tesseract_src_dir) .include(&stim_src_dir) + .include(boost_dir) .include("include") .include("src") .define("TESSERACT_BRIDGE_EXPORTS", None); @@ -146,10 +164,11 @@ fn build_cxx_bridge(tesseract_dir: &Path, stim_dir: &Path) { .flag_if_supported("/permissive-") .flag_if_supported("/Zc:__cplusplus"); - // Force include standard headers that external libraries assume are available - // MSVC is stricter than GCC/Clang about transitive includes - build.flag("/FI").flag("array"); // For std::array - build.flag("/FI").flag("numeric"); // For std::iota + // Force include standard headers that vendored code assumes are + // transitively available. MSVC is stricter than GCC/Clang. + build.flag("/FI").flag("array"); + build.flag("/FI").flag("numeric"); + build.flag("/FI").flag("algorithm"); } build.compile("tesseract-bridge"); diff --git a/crates/pecos-tesseract/examples/tesseract_usage.rs b/crates/pecos-tesseract/examples/tesseract_usage.rs index 7500fc0bb..40b6814c0 100644 --- a/crates/pecos-tesseract/examples/tesseract_usage.rs +++ b/crates/pecos-tesseract/examples/tesseract_usage.rs @@ -131,7 +131,6 @@ error(0.0005) D1 D3 L0 det_beam: 50, beam_climbing: true, no_revisit_dets: false, - at_most_two_errors_per_detector: true, verbose: false, pqlimit: 10000, det_penalty: 0.05, @@ -161,10 +160,6 @@ error(0.0005) D1 D3 L0 " No revisit detectors: {}", custom_decoder.no_revisit_dets() ); - println!( - " At most two errors per detector: {}", - custom_decoder.at_most_two_errors_per_detector() - ); println!(" Priority queue limit: {}", custom_decoder.pqlimit()); println!(" Detector penalty: {:.3}", custom_decoder.det_penalty()); diff --git a/crates/pecos-tesseract/include/tesseract_bridge.h b/crates/pecos-tesseract/include/tesseract_bridge.h index 75ea89fea..d87177c86 100644 --- a/crates/pecos-tesseract/include/tesseract_bridge.h +++ b/crates/pecos-tesseract/include/tesseract_bridge.h @@ -30,7 +30,6 @@ class TesseractDecoderWrapper { uint16_t get_det_beam() const; bool get_beam_climbing() const; bool get_no_revisit_dets() const; - bool get_at_most_two_errors_per_detector() const; bool get_verbose() const; size_t get_pqlimit() const; double get_det_penalty() const; @@ -74,7 +73,6 @@ size_t get_num_observables(const TesseractDecoderWrapper& decoder); uint16_t get_det_beam(const TesseractDecoderWrapper& decoder); bool get_beam_climbing(const TesseractDecoderWrapper& decoder); bool get_no_revisit_dets(const TesseractDecoderWrapper& decoder); -bool get_at_most_two_errors_per_detector(const TesseractDecoderWrapper& decoder); bool get_verbose(const TesseractDecoderWrapper& decoder); size_t get_pqlimit(const TesseractDecoderWrapper& decoder); double get_det_penalty(const TesseractDecoderWrapper& decoder); diff --git a/crates/pecos-tesseract/pecos.toml b/crates/pecos-tesseract/pecos.toml index 7d0dab5e3..4e7316ed3 100644 --- a/crates/pecos-tesseract/pecos.toml +++ b/crates/pecos-tesseract/pecos.toml @@ -4,13 +4,19 @@ version = 1 [dependencies.stim] -version = "bd60b73525fd5a9b30839020eb7554ad369e4337" -url = "https://github.com/quantumlib/Stim/archive/bd60b73525fd5a9b30839020eb7554ad369e4337.tar.gz" -sha256 = "2a4be24295ce3018d79e08369b31e401a2d33cd8b3a75675d57dac3afd9de37d" +version = "213275795e49027772bea7c610b6aac3a80583e1" +url = "https://github.com/quantumlib/Stim/archive/213275795e49027772bea7c610b6aac3a80583e1.tar.gz" +sha256 = "b63c4ae94494a8819d440757776cf80a829ec1e30454fa7c9b0f670e981938ab" description = "Stabilizer simulator for QEC" +[dependencies.boost] +version = "1.83.0" +url = "https://archives.boost.io/release/1.83.0/source/boost_1_83_0.tar.bz2" +sha256 = "6478edfe2f3305127cffe8caf73ea0176c53769f4bf1585be237eb30798c3b8e" +description = "C++ Boost libraries" + [dependencies.tesseract] -version = "1d81f0b385b6a9de49ae361d08bd6b5dbcec1773" -url = "https://github.com/quantumlib/tesseract-decoder/archive/1d81f0b385b6a9de49ae361d08bd6b5dbcec1773.tar.gz" -sha256 = "0b5d8bfa63bab68ab4882510a96d7e238d598d2ba0e669a8903af142ce276892" +version = "56dd8d5fee3834ef85ca9475a10e708f7d245bea" +url = "https://github.com/quantumlib/tesseract-decoder/archive/56dd8d5fee3834ef85ca9475a10e708f7d245bea.tar.gz" +sha256 = "8736ec264fb9a6310957470ac3c63a6d1ce2a26002821caeb64ad91148893e9e" description = "Tesseract decoder" diff --git a/crates/pecos-tesseract/src/bridge.cpp b/crates/pecos-tesseract/src/bridge.cpp index 65260dedb..9366bfa63 100644 --- a/crates/pecos-tesseract/src/bridge.cpp +++ b/crates/pecos-tesseract/src/bridge.cpp @@ -5,7 +5,9 @@ #include #include #include -#include // Required for std::iota on MSVC +#include // Required for std::iota on MSVC +#include // std::mt19937, std::shuffle +#include // std::shuffle // Include Tesseract headers #include "tesseract.h" @@ -40,28 +42,21 @@ class TesseractDecoderWrapper::Impl { INF_DET_BEAM : static_cast(config_repr.det_beam); config.beam_climbing = config_repr.beam_climbing; config.no_revisit_dets = config_repr.no_revisit_dets; - config.at_most_two_errors_per_detector = config_repr.at_most_two_errors_per_detector; config.verbose = config_repr.verbose; + config.merge_errors = true; config.pqlimit = config_repr.pqlimit; config.det_penalty = config_repr.det_penalty; - // Initialize detector orders with a default ordering - if (config.det_orders.empty()) { - std::vector default_order; - size_t num_dets = config.dem.count_detectors(); - for (size_t i = 0; i < num_dets; ++i) { - default_order.push_back(i); - } - config.det_orders.push_back(default_order); - } + // Generate BFS-based detector orderings (upstream default). + // BFS on the detector graph produces spatially-aware orderings + // that help the A* search find short paths faster. + config.det_orders = build_det_orders(config.dem, 20, DetOrder::DetBFS, 2384753); config_ = config; decoder_ = std::make_unique(std::move(config)); } DecodingResultRepr decode_detections(const rust::Slice detections) { - // Use data()+size() instead of begin()/end() iterators to avoid - // Xcode 15.4 libc++ pointer_traits incompatibility with cxx iterators in C++20 std::vector det_vec(detections.data(), detections.data() + detections.size()); decoder_->decode_to_errors(det_vec); @@ -72,7 +67,8 @@ class TesseractDecoderWrapper::Impl { result.predicted_errors.push_back(err); } - result.observables_mask = decoder_->mask_from_errors(decoder_->predicted_errors_buffer); + result.observables_mask = vector_to_u64_mask( + decoder_->get_flipped_observables(decoder_->predicted_errors_buffer)); result.cost = decoder_->cost_from_errors(decoder_->predicted_errors_buffer); result.low_confidence = decoder_->low_confidence_flag; @@ -85,7 +81,7 @@ class TesseractDecoderWrapper::Impl { ) { std::vector det_vec(detections.data(), detections.data() + detections.size()); - decoder_->decode_to_errors(det_vec, det_order); + decoder_->decode_to_errors(det_vec, det_order, config_.det_beam); DecodingResultRepr result; result.predicted_errors = rust::Vec(); @@ -93,7 +89,8 @@ class TesseractDecoderWrapper::Impl { result.predicted_errors.push_back(err); } - result.observables_mask = decoder_->mask_from_errors(decoder_->predicted_errors_buffer); + result.observables_mask = vector_to_u64_mask( + decoder_->get_flipped_observables(decoder_->predicted_errors_buffer)); result.cost = decoder_->cost_from_errors(decoder_->predicted_errors_buffer); result.low_confidence = decoder_->low_confidence_flag; @@ -125,10 +122,6 @@ class TesseractDecoderWrapper::Impl { return config_.no_revisit_dets; } - bool get_at_most_two_errors_per_detector() const { - return config_.at_most_two_errors_per_detector; - } - bool get_verbose() const { return config_.verbose; } @@ -145,7 +138,7 @@ class TesseractDecoderWrapper::Impl { if (error_idx >= decoder_->errors.size()) { throw std::out_of_range("Error index out of range"); } - return decoder_->errors[error_idx].probability; + return decoder_->errors[error_idx].get_probability(); } double get_error_cost(size_t error_idx) const { @@ -171,31 +164,17 @@ class TesseractDecoderWrapper::Impl { if (error_idx >= decoder_->errors.size()) { throw std::out_of_range("Error index out of range"); } - return decoder_->errors[error_idx].symptom.observables; + return vector_to_u64_mask(decoder_->errors[error_idx].symptom.observables); } uint64_t mask_from_errors(const rust::Slice error_indices) const { - // Work around Tesseract bug: functions ignore parameter and use internal buffer - // So we calculate the mask ourselves - uint64_t mask = 0; - for (size_t ei : error_indices) { - if (ei < decoder_->errors.size()) { - mask ^= decoder_->errors[ei].symptom.observables; - } - } - return mask; + std::vector indices(error_indices.data(), error_indices.data() + error_indices.size()); + return vector_to_u64_mask(decoder_->get_flipped_observables(indices)); } double cost_from_errors(const rust::Slice error_indices) const { - // Work around Tesseract bug: functions ignore parameter and use internal buffer - // So we calculate the cost ourselves - double total_cost = 0; - for (size_t ei : error_indices) { - if (ei < decoder_->errors.size()) { - total_cost += decoder_->errors[ei].likelihood_cost; - } - } - return total_cost; + std::vector indices(error_indices.data(), error_indices.data() + error_indices.size()); + return decoder_->cost_from_errors(indices); } }; @@ -245,9 +224,6 @@ bool TesseractDecoderWrapper::get_no_revisit_dets() const { return pimpl_->get_no_revisit_dets(); } -bool TesseractDecoderWrapper::get_at_most_two_errors_per_detector() const { - return pimpl_->get_at_most_two_errors_per_detector(); -} bool TesseractDecoderWrapper::get_verbose() const { return pimpl_->get_verbose(); @@ -345,10 +321,6 @@ bool get_no_revisit_dets(const TesseractDecoderWrapper& decoder) { return decoder.get_no_revisit_dets(); } -bool get_at_most_two_errors_per_detector(const TesseractDecoderWrapper& decoder) { - return decoder.get_at_most_two_errors_per_detector(); -} - bool get_verbose(const TesseractDecoderWrapper& decoder) { return decoder.get_verbose(); } diff --git a/crates/pecos-tesseract/src/bridge.rs b/crates/pecos-tesseract/src/bridge.rs index f22e68d26..f73a72528 100644 --- a/crates/pecos-tesseract/src/bridge.rs +++ b/crates/pecos-tesseract/src/bridge.rs @@ -11,7 +11,6 @@ pub(crate) mod ffi { pub det_beam: u16, pub beam_climbing: bool, pub no_revisit_dets: bool, - pub at_most_two_errors_per_detector: bool, pub verbose: bool, pub pqlimit: usize, pub det_penalty: f64, @@ -80,9 +79,6 @@ pub(crate) mod ffi { /// Check if detector revisiting is disabled. fn get_no_revisit_dets(decoder: &TesseractDecoderWrapper) -> bool; - /// Check if at-most-two-errors-per-detector is enabled. - fn get_at_most_two_errors_per_detector(decoder: &TesseractDecoderWrapper) -> bool; - /// Check if verbose mode is enabled. fn get_verbose(decoder: &TesseractDecoderWrapper) -> bool; diff --git a/crates/pecos-tesseract/src/decoder.rs b/crates/pecos-tesseract/src/decoder.rs index a58068d28..4962d2163 100644 --- a/crates/pecos-tesseract/src/decoder.rs +++ b/crates/pecos-tesseract/src/decoder.rs @@ -45,8 +45,6 @@ pub struct TesseractConfig { pub beam_climbing: bool, /// Avoid revisiting detectors during search pub no_revisit_dets: bool, - /// Limit to at most two errors per detector - pub at_most_two_errors_per_detector: bool, /// Enable verbose output pub verbose: bool, /// Priority queue size limit @@ -60,10 +58,9 @@ impl Default for TesseractConfig { Self { det_beam: u16::MAX, // Infinite beam by default beam_climbing: false, - no_revisit_dets: false, - at_most_two_errors_per_detector: false, + no_revisit_dets: true, verbose: false, - pqlimit: usize::MAX, + pqlimit: 200_000, det_penalty: 0.0, } } @@ -74,12 +71,11 @@ impl TesseractConfig { #[must_use] pub fn fast() -> Self { Self { - det_beam: 100, + det_beam: 5, beam_climbing: true, no_revisit_dets: true, - at_most_two_errors_per_detector: true, verbose: false, - pqlimit: 1_000_000, + pqlimit: 200_000, det_penalty: 0.1, } } @@ -91,9 +87,8 @@ impl TesseractConfig { det_beam: u16::MAX, beam_climbing: false, no_revisit_dets: false, - at_most_two_errors_per_detector: false, verbose: false, - pqlimit: usize::MAX, + pqlimit: 1_000_000, det_penalty: 0.0, } } @@ -105,7 +100,6 @@ impl TesseractConfig { det_beam: self.det_beam, beam_climbing: self.beam_climbing, no_revisit_dets: self.no_revisit_dets, - at_most_two_errors_per_detector: self.at_most_two_errors_per_detector, verbose: self.verbose, pqlimit: self.pqlimit, det_penalty: self.det_penalty, @@ -336,12 +330,6 @@ impl TesseractDecoder { ffi::get_no_revisit_dets(&self.inner) } - /// Check if at-most-two-errors-per-detector is enabled - #[must_use] - pub fn at_most_two_errors_per_detector(&self) -> bool { - ffi::get_at_most_two_errors_per_detector(&self.inner) - } - /// Check if verbose mode is enabled #[must_use] pub fn verbose(&self) -> bool { @@ -361,6 +349,24 @@ impl TesseractDecoder { } } +impl pecos_decoder_core::ObservableDecoder for TesseractDecoder { + fn decode_to_observables( + &mut self, + syndrome: &[u8], + ) -> Result { + let detections: Vec = syndrome + .iter() + .enumerate() + .filter_map(|(i, &val)| if val != 0 { Some(i as u64) } else { None }) + .collect(); + let det_arr = Array1::from_vec(detections); + let result = self + .decode_detections(&det_arr.view()) + .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; + Ok(result.observables_mask) + } +} + impl Decoder for TesseractDecoder { type Result = DecodingResult; type Error = TesseractError; @@ -416,10 +422,9 @@ mod tests { #[test] fn test_tesseract_config_fast() { let config = TesseractConfig::fast(); - assert_eq!(config.det_beam, 100); + assert_eq!(config.det_beam, 5); assert!(config.beam_climbing); assert!(config.no_revisit_dets); - assert!(config.at_most_two_errors_per_detector); } #[test] @@ -428,6 +433,5 @@ mod tests { assert_eq!(config.det_beam, u16::MAX); assert!(!config.beam_climbing); assert!(!config.no_revisit_dets); - assert!(!config.at_most_two_errors_per_detector); } } diff --git a/crates/pecos-tesseract/tests/tesseract/tesseract_comprehensive_tests.rs b/crates/pecos-tesseract/tests/tesseract/tesseract_comprehensive_tests.rs index 6ef83e46a..27c49d542 100644 --- a/crates/pecos-tesseract/tests/tesseract/tesseract_comprehensive_tests.rs +++ b/crates/pecos-tesseract/tests/tesseract/tesseract_comprehensive_tests.rs @@ -166,7 +166,7 @@ error(0.1) D2 D3 // Test fast configuration let fast_config = TesseractConfig::fast(); let mut fast_decoder = TesseractDecoder::new(dem, fast_config).unwrap(); - assert_eq!(fast_decoder.det_beam(), 100); + assert_eq!(fast_decoder.det_beam(), 5); assert!(fast_decoder.beam_climbing()); // Test accurate configuration @@ -250,10 +250,9 @@ fn test_configuration_getters() { let dem = "error(0.1) D0"; let custom_config = TesseractConfig { - det_beam: 50, + det_beam: 5, beam_climbing: true, no_revisit_dets: false, - at_most_two_errors_per_detector: true, verbose: false, pqlimit: 5000, det_penalty: 0.05, @@ -262,10 +261,9 @@ fn test_configuration_getters() { let decoder = TesseractDecoder::new(dem, custom_config).unwrap(); // Verify all configuration values - assert_eq!(decoder.det_beam(), 50); + assert_eq!(decoder.det_beam(), 5); assert!(decoder.beam_climbing()); assert!(!decoder.no_revisit_dets()); - assert!(decoder.at_most_two_errors_per_detector()); assert!(!decoder.verbose()); assert_eq!(decoder.pqlimit(), 5000); assert!((decoder.det_penalty() - 0.05).abs() < 0.001); diff --git a/crates/pecos-tesseract/tests/tesseract/tesseract_tests.rs b/crates/pecos-tesseract/tests/tesseract/tesseract_tests.rs index bba9578f3..b2b96b62a 100644 --- a/crates/pecos-tesseract/tests/tesseract/tesseract_tests.rs +++ b/crates/pecos-tesseract/tests/tesseract/tesseract_tests.rs @@ -9,10 +9,9 @@ fn test_tesseract_config_default() { let config = TesseractConfig::default(); assert_eq!(config.det_beam, u16::MAX); assert!(!config.beam_climbing); - assert!(!config.no_revisit_dets); - assert!(!config.at_most_two_errors_per_detector); + assert!(config.no_revisit_dets); assert!(!config.verbose); - assert_eq!(config.pqlimit, usize::MAX); + assert_eq!(config.pqlimit, 200_000); assert!( config.det_penalty.abs() < f64::EPSILON, "det_penalty should be 0.0 but was {}", @@ -23,12 +22,11 @@ fn test_tesseract_config_default() { #[test] fn test_tesseract_config_fast() { let config = TesseractConfig::fast(); - assert_eq!(config.det_beam, 100); + assert_eq!(config.det_beam, 5); assert!(config.beam_climbing); assert!(config.no_revisit_dets); - assert!(config.at_most_two_errors_per_detector); assert!(!config.verbose); - assert_eq!(config.pqlimit, 1_000_000); + assert_eq!(config.pqlimit, 200_000); assert!( (config.det_penalty - 0.1).abs() < f64::EPSILON, "det_penalty should be 0.1 but was {}", @@ -42,9 +40,8 @@ fn test_tesseract_config_accurate() { assert_eq!(config.det_beam, u16::MAX); assert!(!config.beam_climbing); assert!(!config.no_revisit_dets); - assert!(!config.at_most_two_errors_per_detector); assert!(!config.verbose); - assert_eq!(config.pqlimit, usize::MAX); + assert_eq!(config.pqlimit, 1_000_000); assert!( config.det_penalty.abs() < f64::EPSILON, "det_penalty should be 0.0 but was {}", @@ -55,20 +52,18 @@ fn test_tesseract_config_accurate() { #[test] fn test_tesseract_config_to_ffi_repr() { let config = TesseractConfig { - det_beam: 50, + det_beam: 5, beam_climbing: true, no_revisit_dets: false, - at_most_two_errors_per_detector: true, verbose: true, pqlimit: 5000, det_penalty: 0.05, }; let ffi_repr = config.to_ffi_repr(); - assert_eq!(ffi_repr.det_beam, 50); + assert_eq!(ffi_repr.det_beam, 5); assert!(ffi_repr.beam_climbing); assert!(!ffi_repr.no_revisit_dets); - assert!(ffi_repr.at_most_two_errors_per_detector); assert!(ffi_repr.verbose); assert_eq!(ffi_repr.pqlimit, 5000); assert!( diff --git a/crates/pecos-uf-decoder/Cargo.toml b/crates/pecos-uf-decoder/Cargo.toml new file mode 100644 index 000000000..27b28a9d4 --- /dev/null +++ b/crates/pecos-uf-decoder/Cargo.toml @@ -0,0 +1,26 @@ +[package] +name = "pecos-uf-decoder" +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 = "Fast syndrome-graph Union-Find decoder for PECOS" + +[dependencies] +pecos-decoder-core.workspace = true +pecos-random.workspace = true +rayon.workspace = true + +[lib] +name = "pecos_uf_decoder" + +[dev-dependencies] +fastrand.workspace = true + +[lints] +workspace = true diff --git a/crates/pecos-uf-decoder/examples/profile_decode.rs b/crates/pecos-uf-decoder/examples/profile_decode.rs new file mode 100644 index 000000000..78bcd54fa --- /dev/null +++ b/crates/pecos-uf-decoder/examples/profile_decode.rs @@ -0,0 +1,144 @@ +// Simple profiling harness for the UF decoder. +// Run with: cargo run --release --example profile_decode -p pecos-uf-decoder + +use pecos_decoder_core::dem::DemMatchingGraph; +use pecos_uf_decoder::{UfDecoder, UfDecoderConfig}; +use std::time::Instant; + +const D3_DEM: &str = + include_str!("../../../examples/surface_code_circuits/surface_code_d3_z_stim.dem"); +const D5_DEM: &str = + include_str!("../../../examples/surface_code_circuits/surface_code_d5_z_stim.dem"); + +fn shots_as_f64(num_shots: usize) -> f64 { + f64::from(u32::try_from(num_shots).expect("profile shot count fits in u32")) +} + +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 num_det = graph.num_detectors; + + // Generate random syndromes + let mut rng = fastrand::Rng::with_seed(42); + let syndromes: Vec> = (0..num_shots) + .map(|_| (0..num_det).map(|_| u8::from(rng.f64() < 0.05)).collect()) + .collect(); + + // Warm up + for syn in &syndromes[..100.min(num_shots)] { + let _ = dec.decode_syndrome(syn); + } + + // Time the full batch + let t0 = Instant::now(); + let mut errors = 0u64; + for syn in &syndromes { + let obs = dec.decode_syndrome(syn); + errors += obs; + } + let elapsed = t0.elapsed(); + + let shots = shots_as_f64(num_shots); + let per_shot_ns = elapsed.as_secs_f64() * 1.0e9 / shots; + let throughput = shots / elapsed.as_secs_f64(); + println!( + "{name:8}: {num_det:3} det, {per_shot_ns:8.0} ns/shot ({:.0} kshots/s), errors={errors}", + throughput / 1000.0 + ); +} + +fn profile_phases(name: &str, dem: &str, num_shots: usize) { + let graph = DemMatchingGraph::from_dem_str(dem).unwrap(); + let num_det = graph.num_detectors; + let num_edges = graph.edges.len(); + + let mut rng = fastrand::Rng::with_seed(42); + let syndromes: Vec> = (0..num_shots) + .map(|_| (0..num_det).map(|_| u8::from(rng.f64() < 0.05)).collect()) + .collect(); + + // Phase 1: measure reset + syndrome loading only + let mut dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::fast()); + let t0 = Instant::now(); + for syn in &syndromes { + dec.syndrome_validate(syn); // reset + grow (no peel) + } + let grow_time = t0.elapsed(); + + // Phase 2: measure full decode (reset + grow + peel) + let t0 = Instant::now(); + for syn in &syndromes { + let _ = dec.decode_syndrome(syn); + } + let total_time = t0.elapsed(); + + let shots = shots_as_f64(num_shots); + let grow_ns = grow_time.as_secs_f64() * 1.0e9 / shots; + let total_ns = total_time.as_secs_f64() * 1.0e9 / shots; + let peel_ns = total_ns - grow_ns; + + println!( + "{name}: {num_det} det, {num_edges} edges | total {total_ns:.0} ns = grow {grow_ns:.0} ns ({:.0}%) + peel {peel_ns:.0} ns ({:.0}%)", + grow_ns / total_ns * 100.0, + peel_ns / total_ns * 100.0, + ); + + // Also profile BP+UF if available + if let Ok(mut bp_dec) = + pecos_uf_decoder::BpUfDecoder::from_dem(dem, pecos_uf_decoder::BpUfConfig::default()) + { + use pecos_decoder_core::ObservableDecoder; + let t0 = Instant::now(); + for syn in &syndromes { + let _ = bp_dec.decode_to_observables(syn); + } + let bp_total = t0.elapsed(); + let bp_ns = bp_total.as_secs_f64() * 1.0e9 / shots; + let bp_only = bp_ns - total_ns; // approximate BP overhead + println!( + " BP+UF: {bp_ns:.0} ns/shot total (BP overhead ~{bp_only:.0} ns = {:.0}%)", + bp_only / bp_ns * 100.0, + ); + } +} + +const D7_DEM: &str = + include_str!("../../../examples/surface_code_circuits/surface_code_d7_z_stim.dem"); + +fn main() { + let num_shots = 100_000; + + println!("=== UF Decoder Profiling ({num_shots} shots) ==="); + println!(); + + profile_decoder("d3", D3_DEM, num_shots); + profile_decoder("d5", D5_DEM, num_shots); + profile_decoder("d7", D7_DEM, num_shots); + + println!(); + println!("=== Phase breakdown ==="); + profile_phases("d3", D3_DEM, num_shots); + profile_phases("d5", D5_DEM, num_shots); + profile_phases("d7", D7_DEM, num_shots); + + // Also profile with balanced config (Prim MST) + 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 num_det = graph.num_detectors; + + let mut rng = fastrand::Rng::with_seed(42); + let syndromes: Vec> = (0..num_shots) + .map(|_| (0..num_det).map(|_| u8::from(rng.f64() < 0.05)).collect()) + .collect(); + + let t0 = Instant::now(); + for syn in &syndromes { + let _ = dec.decode_syndrome(syn); + } + let elapsed = t0.elapsed(); + let per_shot_ns = elapsed.as_secs_f64() * 1.0e9 / shots_as_f64(num_shots); + println!("d5-bal : {num_det:3} det, {per_shot_ns:8.0} ns/shot"); +} diff --git a/crates/pecos-uf-decoder/src/astar.rs b/crates/pecos-uf-decoder/src/astar.rs new file mode 100644 index 000000000..1b0b56bc8 --- /dev/null +++ b/crates/pecos-uf-decoder/src/astar.rs @@ -0,0 +1,501 @@ +// 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. + +//! A* error-set decoder inspired by Tesseract (Google, arXiv:2503.10988). +//! +//! Searches over error mechanism subsets to find the minimum-weight +//! error set consistent with the syndrome. Uses `DetCost` heuristic +//! (per-detector minimum mechanism cost) for admissible pruning. +//! +//! Key pruning strategies from Tesseract: +//! - **Canonical expansion**: only expand mechanisms incident to the +//! lowest-index unsatisfied detector +//! - **No-revisit-dets**: skip states with previously-seen residual syndromes +//! - **Beam**: skip states with too many residual defects +//! - **PQ limit**: terminate after a fixed number of expansions +//! +//! Uses u64 bitsets for compact state representation and fast operations. + +use pecos_decoder_core::ObservableDecoder; +use pecos_decoder_core::dem::DemMatchingGraph; +use pecos_decoder_core::errors::DecoderError; +use std::cmp::Reverse; +use std::collections::{BinaryHeap, HashSet}; + +/// A mechanism (error) in the DEM. +struct Mechanism { + detectors: Vec, + obs_mask: u64, + weight: f64, +} + +/// Configuration for the A* decoder. +#[derive(Debug, Clone, Copy)] +pub struct AStarConfig { + /// Maximum priority queue pops before terminating. + pub pq_limit: usize, + /// Beam: skip states with > beam more residual defects than best seen. + pub beam: usize, +} + +impl Default for AStarConfig { + fn default() -> Self { + Self { + pq_limit: 50_000, + beam: 20, + } + } +} + +/// Compact bitset for detector/mechanism membership. +#[derive(Clone, PartialEq, Eq, Hash)] +struct Bitset { + words: Vec, +} + +impl Bitset { + fn new(n: usize) -> Self { + Self { + words: vec![0u64; n.div_ceil(64)], + } + } + + fn get(&self, i: usize) -> bool { + let (word, bit) = (i / 64, i % 64); + word < self.words.len() && (self.words[word] & (1u64 << bit)) != 0 + } + + fn set(&mut self, i: usize) { + let (word, bit) = (i / 64, i % 64); + if word < self.words.len() { + self.words[word] |= 1u64 << bit; + } + } + + fn flip(&mut self, i: usize) { + let (word, bit) = (i / 64, i % 64); + if word < self.words.len() { + self.words[word] ^= 1u64 << bit; + } + } + + fn count_ones(&self) -> usize { + self.words.iter().map(|w| w.count_ones() as usize).sum() + } + + /// Find the index of the lowest set bit, or None. + fn lowest_set(&self) -> Option { + for (wi, &w) in self.words.iter().enumerate() { + if w != 0 { + return Some(wi * 64 + w.trailing_zeros() as usize); + } + } + None + } +} + +/// A* error-set search decoder. +pub struct AStarDecoder { + mechanisms: Vec, + /// Per-detector: list of mechanism indices incident to this detector. + det_to_mechs: Vec>, + num_detectors: usize, + num_mechanisms: usize, + config: AStarConfig, +} + +/// State in the A* search (compact representation). +struct SearchState { + errors: Bitset, + residual: Bitset, + num_residual: usize, + g_cost: f64, + obs_mask: u64, + /// Per-detector: how many included errors are incident to this detector. + det_error_count: Vec, + /// Mechanisms forbidden by `ByPrecedence`: were available at an earlier + /// step but not chosen. Cannot be added in future steps. + forbidden: Bitset, +} + +impl AStarDecoder { + /// Build from a DEM string (graphlike — 2-detector edges only). + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed. + pub fn from_dem(dem: &str, config: AStarConfig) -> Result { + let graph = DemMatchingGraph::from_dem_str(dem)?; + let num_detectors = graph.num_detectors; + + let mut mechanisms = Vec::new(); + let mut det_to_mechs: Vec> = vec![Vec::new(); num_detectors]; + + for edge in &graph.edges { + let mut detectors = vec![edge.node1]; + if let Some(n2) = edge.node2 { + detectors.push(n2); + } + + let obs_mask: u64 = edge + .observables + .iter() + .fold(0u64, |mask, &o| mask | (1 << o)); + + let mech_idx = mechanisms.len(); + for &d in &detectors { + if (d as usize) < num_detectors { + det_to_mechs[d as usize].push(mech_idx); + } + } + + mechanisms.push(Mechanism { + detectors, + obs_mask, + weight: edge.weight, + }); + } + + let num_mechanisms = mechanisms.len(); + + Ok(Self { + mechanisms, + det_to_mechs, + num_detectors, + num_mechanisms, + config, + }) + } + + /// Build from a non-decomposed DEM (preserves hyperedges with 3+ detectors). + /// + /// This gives the A* search access to the full error structure including + /// Y-error correlations that decomposition loses. + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed. + pub fn from_dem_full(dem: &str, config: AStarConfig) -> Result { + use pecos_decoder_core::dem::DemCheckMatrix; + + let dcm = DemCheckMatrix::from_dem_str(dem) + .map_err(|e| DecoderError::InvalidGraph(e.to_string()))?; + let num_detectors = dcm.num_detectors; + + let mut mechanisms = Vec::new(); + let mut det_to_mechs: Vec> = vec![Vec::new(); num_detectors]; + + for m in 0..dcm.num_mechanisms { + let p = dcm.error_priors[m]; + if p <= 0.0 { + continue; + } + + let detectors: Vec = (0..dcm.num_detectors) + .filter(|&d| dcm.check_matrix[[d, m]] != 0) + .map(|d| d as u32) + .collect(); + + if detectors.is_empty() { + continue; + } + + let weight = if p < 1.0 { ((1.0 - p) / p).ln() } else { 0.0 }; + + let mut obs_mask = 0u64; + for o in 0..dcm.num_observables { + if dcm.observable_matrix[[o, m]] != 0 { + obs_mask |= 1 << o; + } + } + + let mech_idx = mechanisms.len(); + for &d in &detectors { + if (d as usize) < num_detectors { + det_to_mechs[d as usize].push(mech_idx); + } + } + + mechanisms.push(Mechanism { + detectors, + obs_mask, + weight, + }); + } + + let num_mechanisms = mechanisms.len(); + + for mechs in &mut det_to_mechs { + mechs.sort_by(|&a, &b| { + mechanisms[a] + .weight + .partial_cmp(&mechanisms[b].weight) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + + Ok(Self { + mechanisms, + det_to_mechs, + num_detectors, + num_mechanisms, + config, + }) + } + + /// Compute `DetCost` heuristic: admissible lower bound on remaining cost. + /// Optimized with early exit: mechanisms per detector are pre-sorted by weight, + /// so once weight exceeds `current_min` * `max_possible_coverage`, we can stop. + fn det_cost(&self, residual: &Bitset, errors: &Bitset) -> f64 { + let mut cost = 0.0; + for (wi, &w) in residual.words.iter().enumerate() { + let mut bits = w; + while bits != 0 { + let bit = bits.trailing_zeros() as usize; + let d = wi * 64 + bit; + bits &= bits - 1; + + if d >= self.num_detectors { + break; + } + + let mut min_cost = f64::INFINITY; + for &m in &self.det_to_mechs[d] { + if errors.get(m) { + continue; + } + let mech = &self.mechanisms[m]; + + // Early skip: if weight / max_coverage >= min_cost, skip. + // max_coverage = number of detectors in this mechanism. + let max_cov = mech.detectors.len() as f64; + if max_cov > 0.0 && mech.weight / max_cov >= min_cost { + continue; + } + + let coverage = mech + .detectors + .iter() + .filter(|&&dd| { + (dd as usize) < self.num_detectors && residual.get(dd as usize) + }) + .count(); + if coverage > 0 { + let c = mech.weight / coverage as f64; + if c < min_cost { + min_cost = c; + } + } + } + if min_cost < f64::INFINITY { + cost += min_cost; + } + } + } + cost + } +} + +impl ObservableDecoder for AStarDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + let n = self.num_detectors; + let m = self.num_mechanisms; + + // Build initial residual. + let mut init_residual = Bitset::new(n); + for (i, &v) in syndrome.iter().enumerate() { + if v != 0 && i < n { + init_residual.set(i); + } + } + let num_defects = init_residual.count_ones(); + if num_defects == 0 { + return Ok(0); + } + + // A* priority queue and visited set. + let mut pq: BinaryHeap<(Reverse, usize)> = BinaryHeap::new(); + let mut states: Vec = Vec::new(); + let mut visited: HashSet = HashSet::new(); + + let init_errors = Bitset::new(m); + let init_h = self.det_cost(&init_residual, &init_errors); + + states.push(SearchState { + errors: init_errors, + residual: init_residual.clone(), + num_residual: num_defects, + g_cost: 0.0, + obs_mask: 0, + det_error_count: vec![0u8; n], + forbidden: Bitset::new(m), + }); + pq.push((Reverse((0.0_f64 + init_h).to_bits()), 0)); + visited.insert(init_residual); + + let mut best_obs = 0u64; + let best_cost = f64::INFINITY; + let mut min_residual = num_defects; + let mut pops = 0; + + while let Some((Reverse(_), state_idx)) = pq.pop() { + pops += 1; + if pops > self.config.pq_limit { + break; + } + + // Extract state data (avoids borrow conflict). + let (s_num_res, s_g_cost, s_obs) = { + let s = &states[state_idx]; + (s.num_residual, s.g_cost, s.obs_mask) + }; + + if s_num_res == 0 { + best_obs = s_obs; + break; // A* first solution is optimal (admissible heuristic). + } + + if s_num_res > min_residual + self.config.beam { + continue; + } + if s_num_res < min_residual { + min_residual = s_num_res; + } + + // Clone for expansion. + let (s_errors, s_residual, s_det_counts, s_forbidden) = { + let s = &states[state_idx]; + ( + s.errors.clone(), + s.residual.clone(), + s.det_error_count.clone(), + s.forbidden.clone(), + ) + }; + + let lowest_det = match s_residual.lowest_set() { + Some(d) if d < n => d, + _ => continue, + }; + + // Collect candidate mechanisms incident to lowest_det. + let candidates: Vec = self.det_to_mechs[lowest_det] + .iter() + .copied() + .filter(|&mi| !s_errors.get(mi) && !s_forbidden.get(mi)) + .collect(); + + for &mech_idx in &candidates { + let mech = &self.mechanisms[mech_idx]; + + // AtMostTwo: skip if adding this mechanism would place >2 errors + // on any single detector. + let at_most_two_ok = mech.detectors.iter().all(|&d| { + let di = d as usize; + di >= n || s_det_counts[di] < 2 + }); + if !at_most_two_ok { + continue; + } + + let mut new_residual = s_residual.clone(); + let mut new_num = s_num_res; + let mut new_det_counts = s_det_counts.clone(); + for &d in &mech.detectors { + let di = d as usize; + if di < n { + if new_residual.get(di) { + new_num -= 1; + } else { + new_num += 1; + } + new_residual.flip(di); + new_det_counts[di] += 1; + } + } + + // ByPrecedence: all other candidate mechanisms at this step + // become forbidden for this child. They were available but not chosen. + let mut new_forbidden = s_forbidden.clone(); + for &other in &candidates { + if other != mech_idx { + new_forbidden.set(other); + } + } + + // No-revisit-dets: skip if we've seen this residual before. + if !visited.insert(new_residual.clone()) { + continue; + } + + let mut new_errors = s_errors.clone(); + new_errors.set(mech_idx); + + let new_g = s_g_cost + mech.weight; + let new_h = self.det_cost(&new_residual, &new_errors); + let new_f = new_g + new_h; + + if new_f >= best_cost { + continue; + } + + let idx = states.len(); + states.push(SearchState { + errors: new_errors, + residual: new_residual, + num_residual: new_num, + g_cost: new_g, + obs_mask: s_obs ^ mech.obs_mask, + det_error_count: new_det_counts, + forbidden: new_forbidden, + }); + pq.push((Reverse(new_f.to_bits()), idx)); + } + } + + Ok(best_obs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const D3_DEM: &str = + include_str!("../../../examples/surface_code_circuits/surface_code_d3_z_stim.dem"); + + #[test] + fn test_astar_construction() { + let dec = AStarDecoder::from_dem(D3_DEM, AStarConfig::default()); + assert!(dec.is_ok()); + } + + #[test] + fn test_astar_no_errors() { + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let mut dec = AStarDecoder::from_dem(D3_DEM, AStarConfig::default()).unwrap(); + let obs = dec + .decode_to_observables(&vec![0u8; graph.num_detectors]) + .unwrap(); + assert_eq!(obs, 0); + } + + #[test] + fn test_astar_single_defect() { + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let mut dec = AStarDecoder::from_dem(D3_DEM, AStarConfig::default()).unwrap(); + let mut syn = vec![0u8; graph.num_detectors]; + syn[0] = 1; + // Should not panic — single defect resolves to boundary. + let _obs = dec.decode_to_observables(&syn).unwrap(); + } +} diff --git a/crates/pecos-uf-decoder/src/bp_uf.rs b/crates/pecos-uf-decoder/src/bp_uf.rs new file mode 100644 index 000000000..0a293ce40 --- /dev/null +++ b/crates/pecos-uf-decoder/src/bp_uf.rs @@ -0,0 +1,617 @@ +// 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. + +//! BP+UF hybrid decoder. +//! +//! Runs truncated min-sum BP to get per-mechanism soft reliability scores, +//! then uses those scores to adjust UF edge weights. Mechanisms that BP +//! identifies as likely errors get lower weights in UF, improving the +//! quality of the UF clustering. +//! +//! This is a three-stage decoder: +//! 1. **BP stage**: 3-5 iterations of min-sum BP on the check matrix +//! 2. **Weight adjustment**: map BP posteriors to UF edge weights +//! 3. **UF stage**: standard weighted UF growth + peeling + +use crate::decoder::{UfDecoder, UfDecoderConfig}; +use crate::mini_bp::{self, BpGraph}; +use pecos_decoder_core::correlated_decoder::MatchingDecoder; +use pecos_decoder_core::dem::{DemCheckMatrix, DemMatchingGraph}; +use pecos_decoder_core::errors::DecoderError; + +/// BP message schedule. +#[derive(Debug, Clone, Copy, Default)] +pub enum BpSchedule { + /// Flooding: update all checks, then all variables. Fast, good for d<=7. + #[default] + Flooding, + /// Serial: after each check update, immediately update connected variables. + /// Better convergence on loopy graphs. Slower but maintains threshold at d>=9. + Serial, +} + +/// Which graph to run BP on. +#[derive(Debug, Clone, Copy, Default)] +pub enum BpGraphType { + /// Auto: matching-graph BP at d<=4, Tanner-graph BP at d>=5. + /// Gets the best of both worlds at each distance. + #[default] + Auto, + /// Tanner graph from check matrix (decomposed DEM). + TannerGraph, + /// Matching graph (pairwise detector edges). Simpler topology, + /// better convergence. Based on Hack et al. (2026). + MatchingGraph, +} + +/// Configuration for the BP+UF hybrid decoder. +#[derive(Debug, Clone, Copy)] +pub struct BpUfConfig { + /// Number of BP iterations before UF. + /// 0 = adaptive (scales with code distance). Default: 0. + pub bp_iterations: usize, + /// BP message schedule. Default: Flooding (fast, good for d<=7). + pub bp_schedule: BpSchedule, + /// Which graph to run BP on. Default: `TannerGraph`. + pub bp_graph_type: BpGraphType, + /// Min-sum scaling factor. Default: 0.625 (normalized min-sum). + pub min_sum_scale: f64, + /// How much BP posteriors influence UF weights. + /// 0.0 = pure UF, 1.0 = fully trust BP. Default: 0.9. + pub bp_weight_blend: f64, + /// UF decoder config. + pub uf_config: UfDecoderConfig, +} + +impl Default for BpUfConfig { + fn default() -> Self { + Self::balanced() + } +} + +impl BpUfConfig { + /// Balanced: flooding BP on Tanner graph, good for d=3-7. Fast. + #[must_use] + pub fn balanced() -> Self { + Self { + bp_iterations: 0, + bp_schedule: BpSchedule::Flooding, + bp_graph_type: BpGraphType::Auto, + min_sum_scale: 0.625, + bp_weight_blend: 0.9, + uf_config: UfDecoderConfig::balanced(), + } + } + + /// Accurate: serial BP, maintains threshold at d=7-11+. Slower. + #[must_use] + pub fn accurate() -> Self { + Self { + bp_iterations: 0, + bp_schedule: BpSchedule::Serial, + bp_graph_type: BpGraphType::Auto, + min_sum_scale: 0.625, + bp_weight_blend: 0.9, + uf_config: UfDecoderConfig::balanced(), + } + } + + /// Matching-graph BP: run BP on the simpler matching graph. + #[must_use] + pub fn matching_bp() -> Self { + Self { + bp_iterations: 0, + bp_schedule: BpSchedule::Flooding, + bp_graph_type: BpGraphType::MatchingGraph, + min_sum_scale: 0.625, + bp_weight_blend: 0.9, + uf_config: UfDecoderConfig::balanced(), + } + } +} + +/// BP+UF hybrid decoder. +pub struct BpUfDecoder { + /// Inner UF decoder. + uf: UfDecoder, + /// Pre-computed sparse BP graph for Tanner graph BP. + bp_graph: BpGraph, + /// Matching graph (stored for matching-graph BP mode). + matching_graph: Option, + /// Mapping from mechanism index to matching graph edge index. + mechanism_to_edge: Vec>, + /// Base edge weights (from DEM, before BP adjustment). + base_weights: Vec, + /// BP-adjusted weights (reusable buffer). + adjusted_weights: Vec, + /// BP message buffers (reusable across shots). + bp_c_to_v: Vec, + bp_v_to_c: Vec, + bp_posterior: Vec, + /// Config. + config: BpUfConfig, +} + +impl BpUfDecoder { + /// Create from a DEM string. + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed. + pub fn from_dem(dem: &str, config: BpUfConfig) -> Result { + let dcm = DemCheckMatrix::from_dem_str(dem) + .map_err(|e| DecoderError::InvalidConfiguration(e.to_string()))?; + let graph = DemMatchingGraph::from_dem_str(dem)?; + let uf = UfDecoder::from_matching_graph(&graph, config.uf_config); + + // Build mechanism → edge mapping. + // Each mechanism in the check matrix corresponds to a column. + // The matching graph merges mechanisms by fault ID into edges. + // We need to find which edge each mechanism ended up in. + // + // Approach: for each mechanism, find which detectors it touches, + // then find the matching edge connecting those detectors. + let mut mechanism_to_edge = vec![None; dcm.num_mechanisms]; + + for (m, mechanism_edge) in mechanism_to_edge + .iter_mut() + .enumerate() + .take(dcm.num_mechanisms) + { + let mut detectors: Vec = Vec::new(); + for d in 0..dcm.num_detectors { + if dcm.check_matrix[[d, m]] != 0 { + detectors.push(d as u32); + } + } + + // Match to graph edge by detector pair. + match detectors.len() { + 1 => { + // Boundary edge: one detector. + let d0 = detectors[0]; + for (idx, edge) in graph.edges.iter().enumerate() { + if edge.node1 == d0 && edge.node2.is_none() { + *mechanism_edge = Some(idx); + break; + } + } + } + 2 => { + // Internal edge: two detectors. + let (d0, d1) = (detectors[0], detectors[1]); + for (idx, edge) in graph.edges.iter().enumerate() { + if (edge.node1 == d0 && edge.node2 == Some(d1)) + || (edge.node1 == d1 && edge.node2 == Some(d0)) + { + *mechanism_edge = Some(idx); + break; + } + } + } + _ => { + // Hyperedge (3+ detectors): skip, no matching graph edge. + } + } + } + + let base_weights: Vec = graph.edges.iter().map(|e| e.weight).collect(); + let adjusted_weights = base_weights.clone(); + + let bp_graph_data = BpGraph::from_dcm(&dcm); + let bp_c_to_v = vec![0.0; bp_graph_data.total_edges]; + let bp_v_to_c = vec![0.0; bp_graph_data.total_edges]; + let bp_posterior = Vec::with_capacity(bp_graph_data.num_vars); + + // Always store matching graph (needed for Auto and MatchingGraph modes). + let matching_graph_stored = Some(graph); + + Ok(Self { + uf, + bp_graph: bp_graph_data, + matching_graph: matching_graph_stored, + mechanism_to_edge, + base_weights, + adjusted_weights, + bp_c_to_v, + bp_v_to_c, + bp_posterior, + config, + }) + } +} + +impl BpUfDecoder { + /// Create from two DEMs: non-decomposed for BP, decomposed for matching graph. + /// + /// The non-decomposed DEM gives BP cleaner soft info (no decomposition + /// artifacts). The decomposed DEM gives the matching graph edge structure + /// needed for MWPM and correlation tables. + /// + /// # Errors + /// + /// Returns `DecoderError` if either DEM is malformed. + pub fn from_dual_dem( + bp_dem: &str, + matching_dem: &str, + config: BpUfConfig, + ) -> Result { + // BP graph from the non-decomposed DEM. + let bp_dcm = DemCheckMatrix::from_dem_str(bp_dem) + .map_err(|e| DecoderError::InvalidConfiguration(e.to_string()))?; + let bp_graph = BpGraph::from_dcm(&bp_dcm); + + // Matching graph and UF from the decomposed DEM. + let match_graph = DemMatchingGraph::from_dem_str(matching_dem)?; + 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]; + for (m, mechanism_edge) in mechanism_to_edge + .iter_mut() + .enumerate() + .take(bp_dcm.num_mechanisms) + { + let mut detectors: Vec = Vec::new(); + for d in 0..bp_dcm.num_detectors { + if bp_dcm.check_matrix[[d, m]] != 0 { + detectors.push(d as u32); + } + } + match detectors.len() { + 1 => { + let d0 = detectors[0]; + for (idx, edge) in match_graph.edges.iter().enumerate() { + if edge.node1 == d0 && edge.node2.is_none() { + *mechanism_edge = Some(idx); + break; + } + } + } + 2 => { + let (d0, d1) = (detectors[0], detectors[1]); + for (idx, edge) in match_graph.edges.iter().enumerate() { + if (edge.node1 == d0 && edge.node2 == Some(d1)) + || (edge.node1 == d1 && edge.node2 == Some(d0)) + { + *mechanism_edge = Some(idx); + break; + } + } + } + _ => {} // Hyperedge + } + } + + let base_weights: Vec = match_graph.edges.iter().map(|e| e.weight).collect(); + let adjusted_weights = base_weights.clone(); + let total_edges = bp_graph.total_edges; + + Ok(Self { + uf, + bp_graph, + matching_graph: None, // dual_dem mode uses Tanner graph BP + mechanism_to_edge, + base_weights, + adjusted_weights, + bp_c_to_v: vec![0.0; total_edges], + bp_v_to_c: vec![0.0; total_edges], + bp_posterior: Vec::with_capacity(bp_dcm.num_mechanisms), + config, + }) + } +} + +impl pecos_decoder_core::bp_matching::BpWeightProvider for BpUfDecoder { + fn compute_weights(&mut self, syndrome: &[u8]) -> Vec { + let num_defects = syndrome.iter().filter(|&&v| v != 0).count(); + + let iters = if self.config.bp_iterations > 0 { + self.config.bp_iterations + } else { + let d_est = ((self + .matching_graph + .as_ref() + .map_or(self.bp_graph.num_checks, |mg| mg.num_detectors) + as f64) + / 2.0) + .sqrt(); + match num_defects { + 2..=3 => (d_est.ceil() as usize).min(3), + 4..=8 => (d_est.ceil() as usize).min(8), + _ => (d_est.ceil() as usize).min(12), + } + }; + + // Estimate code distance from matching graph detectors. + // For surface codes: num_detectors ≈ num_stab * num_rounds ≈ d * 2d = 2d² + // so d ≈ sqrt(num_detectors / 2). + let num_det = self + .matching_graph + .as_ref() + .map_or(self.bp_graph.num_checks, |mg| mg.num_detectors); + let d_est = ((num_det as f64) / 2.0).sqrt(); + let use_matching_graph = match self.config.bp_graph_type { + BpGraphType::MatchingGraph => true, + BpGraphType::TannerGraph => false, + BpGraphType::Auto => d_est < 5.5, // Matching graph at d<=4 (d_est≈4.9 at d=3) + }; + + if let (true, Some(mg)) = (use_matching_graph, &self.matching_graph) { + // Matching-graph BP: simpler topology, better convergence. + self.bp_posterior = + mini_bp::matching_graph_bp(mg, syndrome, iters, self.config.min_sum_scale); + // Matching-graph BP posteriors are already per-edge (no mechanism mapping needed). + // Return them directly as weights. + let mut weights = self.base_weights.clone(); + let d_est = ((self + .matching_graph + .as_ref() + .map_or(self.bp_graph.num_checks, |mg| mg.num_detectors) + as f64) + / 2.0) + .sqrt(); + let selectivity = 0.2 * d_est.max(1.0) / 3.0; + for (edge_idx, &posterior) in self.bp_posterior.iter().enumerate() { + if edge_idx < weights.len() { + let prior = self.base_weights[edge_idx]; + let shift = (posterior - prior).abs(); + if shift > selectivity * prior.abs().max(0.1) { + let bp_weight = if posterior > 10.0 { + posterior + } else if posterior < -10.0 { + 0.01 + } else { + (1.0 + posterior.exp()).ln() + }; + let blend = 0.5; + weights[edge_idx] = (1.0 - blend) * prior + blend * bp_weight; + } + } + } + return weights; + } + + // At d>=5 with auto mode, selective BP rarely adjusts edges. + // Skip BP entirely and return base weights (= FB_correlated behavior). + // The correlation table second pass in BpMatchingDecoder handles accuracy. + if matches!(self.config.bp_graph_type, BpGraphType::Auto) && d_est >= 5.5 { + return self.base_weights.clone(); + } + + // Tanner-graph BP. + self.bp_c_to_v.fill(0.0); + self.bp_v_to_c.fill(0.0); + let serial = matches!(self.config.bp_schedule, BpSchedule::Serial); + mini_bp::min_sum_bp_into( + &self.bp_graph, + syndrome, + iters, + self.config.min_sum_scale, + serial, + &mut self.bp_c_to_v, + &mut self.bp_v_to_c, + &mut self.bp_posterior, + ); + + // Selective BP: only adjust edges where BP strongly disagrees with + // the DEM prior. This avoids BP noise (which hurts at d>=5) while + // capturing genuine syndrome-dependent information for edges where + // BP has high confidence. + // + // An edge is adjusted if |posterior - prior| > threshold * |prior|. + // This means BP must shift the LLR by a significant fraction of the + // prior to be trusted. + let mut weights = self.base_weights.clone(); + let d_est = ((self + .matching_graph + .as_ref() + .map_or(self.bp_graph.num_checks, |mg| mg.num_detectors) as f64) + / 2.0) + .sqrt(); + // Selectivity threshold: higher at large d (BP less reliable). + // At d=3: threshold=0.3 (accept moderate BP shifts). + // At d=7: threshold=0.8 (only trust strong BP shifts). + // At d=11: threshold=1.2 (very selective). + let selectivity = 0.2 * d_est.max(1.0) / 3.0; + + for (m, &posterior) in self.bp_posterior.iter().enumerate() { + if let Some(edge_idx) = self.mechanism_to_edge[m] { + let prior = self.bp_graph.prior_llr[m]; + let shift = (posterior - prior).abs(); + + // Only use BP weight if the shift is large relative to the prior. + if shift > selectivity * prior.abs().max(0.1) { + let bp_weight = if posterior > 10.0 { + posterior + } else if posterior < -10.0 { + 0.01 + } else { + (1.0 + posterior.exp()).ln() + }; + // Blend with a moderate factor for selected edges. + let blend = 0.5; + let blended = (1.0 - blend) * self.base_weights[edge_idx] + blend * bp_weight; + weights[edge_idx] = weights[edge_idx].min(blended); + } + } + } + weights + } + + fn num_edges(&self) -> usize { + self.base_weights.len() + } + + fn is_trivial(&self, syndrome: &[u8]) -> Option { + self.uf.predecode_clusters(syndrome) + } +} + +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) { + return Ok(obs); + } + + let num_defects = syndrome.iter().filter(|&&v| v != 0).count(); + + // Stage 1: Run truncated BP (reusing pre-allocated buffers). + // Adaptive iterations: need enough for messages to propagate + // across the graph, but not so many that BP oscillates. + let iters = if self.config.bp_iterations > 0 { + self.config.bp_iterations + } else { + // Estimate code distance from detector count. + // Surface code: num_detectors ≈ d * num_rounds, num_rounds ≈ 2d + // So num_detectors ≈ 2d^2, d ≈ sqrt(num_det / 2) + let d_est = ((self + .matching_graph + .as_ref() + .map_or(self.bp_graph.num_checks, |mg| mg.num_detectors) + as f64) + / 2.0) + .sqrt(); + // Need ~d iterations for full propagation. + // Scale down for few defects. + let target = d_est.ceil() as usize; + match num_defects { + 2..=3 => target.min(3), // few defects: local info sufficient + 4..=8 => target.min(8), // moderate: need more propagation + _ => target.min(12), // many defects: full propagation, capped + } + }; + + self.bp_c_to_v.fill(0.0); + self.bp_v_to_c.fill(0.0); + let serial = matches!(self.config.bp_schedule, BpSchedule::Serial); + mini_bp::min_sum_bp_into( + &self.bp_graph, + syndrome, + iters, + self.config.min_sum_scale, + serial, + &mut self.bp_c_to_v, + &mut self.bp_v_to_c, + &mut self.bp_posterior, + ); + let posteriors = &self.bp_posterior; + + // Stage 2: Adjust UF edge weights using BP posteriors. + // + // BP posterior is an LLR: positive = likely no error, negative = likely error. + // UF weight = ln((1-p)/p): positive, lower = more likely error. + // Both are LLRs with the same sign convention. + // + // The posterior directly replaces the prior LLR as a better estimate. + // We blend to avoid over-reliance on BP when it hasn't converged. + self.adjusted_weights.copy_from_slice(&self.base_weights); + + let blend = self.config.bp_weight_blend; + for (m, &posterior) in posteriors.iter().enumerate() { + if let Some(edge_idx) = self.mechanism_to_edge[m] { + // Map posterior LLR to positive UF weight. + // Positive posterior = unlikely error = high weight. + // Negative posterior = likely error = low weight. + // Soft mapping: weight = log(1 + exp(posterior)) keeps + // weights positive and smooth, approaching 0 for very + // negative posteriors and linear for positive ones. + let bp_weight = if posterior > 10.0 { + posterior // Avoid overflow in exp + } else if posterior < -10.0 { + 0.01 // Very likely error + } else { + (1.0 + posterior.exp()).ln() + }; + let blended = (1.0 - blend) * self.base_weights[edge_idx] + blend * bp_weight; + // Take the minimum across mechanisms for this edge. + self.adjusted_weights[edge_idx] = self.adjusted_weights[edge_idx].min(blended); + } + } + + // Stage 3: Use UF with BP-adjusted weights. + let (mask, matched_edges) = self + .uf + .decode_with_weights(syndrome, &self.adjusted_weights)?; + + // Stage 4 (optional): Second pass -- use first-pass correction to + // boost BP priors for matched edges, re-run BP, re-decode. + // Only do this when the first pass found a substantial correction + // and BP had enough iterations to produce meaningful posteriors. + if matched_edges.len() >= 2 && iters >= 4 { + let boost = 1.5; + for &edge_idx in &matched_edges { + if edge_idx < self.adjusted_weights.len() { + self.adjusted_weights[edge_idx] = + (self.adjusted_weights[edge_idx] - boost).max(0.01); + } + } + let (mask2, _) = self + .uf + .decode_with_weights(syndrome, &self.adjusted_weights)?; + return Ok(mask2); + } + + Ok(mask) + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pecos_decoder_core::ObservableDecoder; + + const SIMPLE_DEM: &str = "\ +error(0.1) D0 D1 L0 +error(0.1) D1 +"; + + #[test] + fn test_bp_uf_construction() { + let dec = BpUfDecoder::from_dem(SIMPLE_DEM, BpUfConfig::default()); + assert!(dec.is_ok()); + } + + #[test] + fn test_bp_uf_no_errors() { + let mut dec = BpUfDecoder::from_dem(SIMPLE_DEM, BpUfConfig::default()).unwrap(); + let obs = dec.decode_to_observables(&[0, 0]).unwrap(); + assert_eq!(obs, 0); + } + + #[test] + fn test_bp_uf_with_errors() { + let mut dec = BpUfDecoder::from_dem(SIMPLE_DEM, BpUfConfig::default()).unwrap(); + let obs = dec.decode_to_observables(&[1, 1]).unwrap(); + assert_eq!(obs, 1); // D0-D1 edge carries L0 + } + + const D3_DEM: &str = + include_str!("../../../examples/surface_code_circuits/surface_code_d3_z_stim.dem"); + + #[test] + fn test_bp_uf_real_dem() { + let mut dec = BpUfDecoder::from_dem(D3_DEM, BpUfConfig::default()).unwrap(); + // No errors + let obs = dec.decode_to_observables(&[0u8; 24]).unwrap(); + assert_eq!(obs, 0); + + // Random syndromes shouldn't panic + let mut rng = fastrand::Rng::with_seed(42); + for _ in 0..100 { + let syn: Vec = (0..24).map(|_| u8::from(rng.f64() < 0.05)).collect(); + let _ = dec.decode_to_observables(&syn).unwrap(); + } + } +} diff --git a/crates/pecos-uf-decoder/src/css_decoder.rs b/crates/pecos-uf-decoder/src/css_decoder.rs new file mode 100644 index 000000000..69baaf239 --- /dev/null +++ b/crates/pecos-uf-decoder/src/css_decoder.rs @@ -0,0 +1,382 @@ +// 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. + +//! CSS-aware Union-Find decoder using the UIUF (Union-Intersection) algorithm. +//! +//! Exploits the CSS structure of surface codes by running UF independently on +//! X and Z syndrome graphs, then identifying likely Y errors via intersection +//! of the two cluster sets. Y errors are promoted to erasures, dramatically +//! improving accuracy (matching or exceeding MWPM). +//! +//! Reference: Tzu-Hao Lin and Ching-Yi Lai, "Union-Intersection Union-Find +//! Decoder," arXiv:2506.14745 (2025). +//! +//! This decoder takes TWO DEM strings (one for X-basis, one for Z-basis) and +//! decodes them jointly. + +use crate::decoder::{UfDecoder, UfDecoderConfig}; +use pecos_decoder_core::dem::{DemMatchingGraph, MatchingEdge}; +use pecos_decoder_core::errors::DecoderError; + +/// Compute the quantized spatial midpoint of an edge from detector coordinates. +/// +/// For UIUF cross-graph matching, we use only the spatial coordinates +/// (first two dimensions) and ignore time (third dimension), since X and Z +/// stabilizers are measured at different times but share the same data qubits. +/// +/// For same-time edges (timelike = measurement errors), the two endpoints +/// share spatial coords, so the midpoint is just that shared spatial position. +/// For space edges (data qubit errors), the midpoint is the data qubit's +/// spatial position. +/// +/// Quantized to 0.001 resolution for use as a map key. +fn edge_spatial_midpoint(edge: &MatchingEdge, coords: &[Option>]) -> Option<(i64, i64)> { + let c1 = coords.get(edge.node1 as usize)?.as_ref()?; + + if let Some(n2) = edge.node2 { + let c2 = coords.get(n2 as usize)?.as_ref()?; + // Spatial midpoint only (ignore time dimension). + let x = ((c1.first().unwrap_or(&0.0) + c2.first().unwrap_or(&0.0)) * 500.0) as i64; + let y = ((c1.get(1).unwrap_or(&0.0) + c2.get(1).unwrap_or(&0.0)) * 500.0) as i64; + Some((x, y)) + } else { + // Boundary edge. + let x = (c1.first().unwrap_or(&0.0) * 1000.0) as i64; + let y = (c1.get(1).unwrap_or(&0.0) * 1000.0) as i64; + Some((x, y)) + } +} + +/// Check if an edge is spatial (connects detectors at the same time) +/// vs timelike (connects detectors at different times). +/// Only spatial edges correspond to data qubit errors. +fn is_spatial_edge(edge: &MatchingEdge, coords: &[Option>]) -> bool { + let Some(c1) = coords.get(edge.node1 as usize).and_then(|c| c.as_ref()) else { + return true; // Assume spatial if no coords. + }; + let Some(n2) = edge.node2 else { + return true; // Boundary edges are spatial. + }; + let Some(c2) = coords.get(n2 as usize).and_then(|c| c.as_ref()) else { + return true; + }; + let t1 = c1.get(2).unwrap_or(&0.0); + let t2 = c2.get(2).unwrap_or(&0.0); + (t1 - t2).abs() < 0.01 // Same time = spatial edge +} + +/// Mapping of shared qubits between X and Z decoding graphs. +/// +/// Each entry represents a data qubit that appears as an edge in both +/// the X and Z matching graphs. During UIUF intersection, if both +/// edges are covered by clusters, the qubit is marked as an erasure. +#[derive(Debug, Clone)] +pub struct QubitEdgeMapping { + /// For each shared qubit: `(edge_idx in X graph, edge_idx in Z graph)`. + pub pairs: Vec<(usize, usize)>, +} + +/// CSS-aware UF decoder using UIUF intersection. +/// +/// Wraps two `UfDecoder` instances (X and Z basis) and exploits the +/// overlap between their cluster sets to identify Y errors. +pub struct CssUfDecoder { + /// UF decoder for X-basis syndromes (decodes Z errors). + x_decoder: UfDecoder, + /// UF decoder for Z-basis syndromes (decodes X errors). + z_decoder: UfDecoder, + /// Number of X detectors (split point for concatenated syndromes). + x_num_detectors: usize, + /// Qubit-to-edge mapping for intersection step. + qubit_map: Option, +} + +impl CssUfDecoder { + /// Create from two DEM strings (X-basis and Z-basis). + /// + /// # Errors + /// + /// Returns `DecoderError` if either DEM is malformed. + pub fn from_dems( + x_dem: &str, + z_dem: &str, + config: UfDecoderConfig, + ) -> Result { + let x_graph = DemMatchingGraph::from_dem_str(x_dem)?; + let z_graph = DemMatchingGraph::from_dem_str(z_dem)?; + + // Auto-detect qubit-edge mapping from detector coordinates. + 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); + + Ok(Self { + x_decoder, + z_decoder, + x_num_detectors, + qubit_map, + }) + } + + /// Build qubit-edge mapping by matching spatial edge midpoints across graphs. + /// + /// Only spatial edges (same-time endpoints = data qubit errors) are matched. + /// Timelike edges (measurement errors) are excluded since they don't + /// correspond to shared data qubits. + /// + /// The mapping pairs edges in the X and Z graphs whose spatial midpoints + /// coincide, identifying the shared data qubit. + fn build_qubit_mapping( + x_graph: &DemMatchingGraph, + z_graph: &DemMatchingGraph, + ) -> Option { + use std::collections::BTreeMap; + + // Collect spatial edges from X graph, keyed by spatial midpoint. + // Multiple X edges can share the same midpoint (different time slices). + // We store all of them and match greedily. + let mut x_midpoints: BTreeMap<(i64, i64), Vec> = BTreeMap::new(); + + for (idx, edge) in x_graph.edges.iter().enumerate() { + if !is_spatial_edge(edge, &x_graph.detector_coords) { + continue; + } + if let Some(mid) = edge_spatial_midpoint(edge, &x_graph.detector_coords) { + x_midpoints.entry(mid).or_default().push(idx); + } + } + + if x_midpoints.is_empty() { + return None; + } + + // Match Z spatial edges against X midpoints. + let mut pairs = Vec::new(); + let mut used_x: std::collections::BTreeSet = std::collections::BTreeSet::new(); + + for (z_idx, edge) in z_graph.edges.iter().enumerate() { + if !is_spatial_edge(edge, &z_graph.detector_coords) { + continue; + } + if let Some(mid) = edge_spatial_midpoint(edge, &z_graph.detector_coords) + && let Some(x_candidates) = x_midpoints.get(&mid) + { + for &x_idx in x_candidates { + if !used_x.contains(&x_idx) { + pairs.push((x_idx, z_idx)); + used_x.insert(x_idx); + break; + } + } + } + } + + if pairs.is_empty() { + None + } else { + Some(QubitEdgeMapping { pairs }) + } + } + + /// Number of qubit pairs in the mapping (0 = no mapping, falls back to independent). + #[must_use] + pub fn num_qubit_pairs(&self) -> usize { + self.qubit_map.as_ref().map_or(0, |m| m.pairs.len()) + } + + /// Set the qubit-to-edge mapping for UIUF intersection. + /// + /// Each pair `(x_edge_idx, z_edge_idx)` identifies a data qubit + /// that appears as an edge in both the X and Z matching graphs. + pub fn set_qubit_mapping(&mut self, mapping: QubitEdgeMapping) { + self.qubit_map = Some(mapping); + } + + /// Decode X and Z syndromes jointly using UIUF. + /// + /// If a qubit-to-edge mapping is set, uses the full UIUF algorithm + /// (intersection to identify Y-error erasures). Otherwise falls back + /// to independent UF decoding on each basis. + /// + /// Returns `(x_obs_mask, z_obs_mask)` -- observable predictions for each basis. + /// + /// # Errors + /// + /// Returns `DecoderError` if decoding fails. + pub fn decode_css( + &mut self, + x_syndrome: &[u8], + z_syndrome: &[u8], + ) -> Result<(u64, u64), DecoderError> { + if let Some(mapping) = &self.qubit_map { + let pairs = mapping.pairs.clone(); + Ok(self.decode_uiuf(x_syndrome, z_syndrome, &pairs)) + } else { + // Fallback: independent decoding. + let x_obs = self.x_decoder.decode_syndrome(x_syndrome); + let z_obs = self.z_decoder.decode_syndrome(z_syndrome); + Ok((x_obs, z_obs)) + } + } + + /// Count erasures that the intersection would produce (diagnostic). + pub fn count_intersection_erasures(&mut self, x_syndrome: &[u8], z_syndrome: &[u8]) -> usize { + if let Some(mapping) = &self.qubit_map { + self.x_decoder.syndrome_validate(x_syndrome); + self.z_decoder.syndrome_validate(z_syndrome); + let mut count = 0; + for &(x_edge, z_edge) in &mapping.pairs { + let x_covered = self.x_decoder.edge_in_cluster(x_edge); + let z_covered = self.z_decoder.edge_in_cluster(z_edge); + if x_covered && z_covered { + count += 1; + } + } + count + } else { + 0 + } + } + + /// Full UIUF algorithm. + fn decode_uiuf( + &mut self, + x_syndrome: &[u8], + z_syndrome: &[u8], + qubit_pairs: &[(usize, usize)], + ) -> (u64, u64) { + // Phase 1: Syndrome validation (growth only) on each graph. + self.x_decoder.syndrome_validate(x_syndrome); + self.z_decoder.syndrome_validate(z_syndrome); + + // Phase 2: Intersection -- find edges covered in BOTH graphs. + // These correspond to likely Y errors (which trigger both X and Z syndromes). + let mut x_erasure_edges: Vec = Vec::new(); + let mut z_erasure_edges: Vec = Vec::new(); + + for &(x_edge, z_edge) in qubit_pairs { + let x_covered = self.x_decoder.edge_in_cluster(x_edge); + let z_covered = self.z_decoder.edge_in_cluster(z_edge); + if x_covered && z_covered { + // Both graphs have clusters covering this qubit's edge. + // Mark as erasure in both graphs for Phase 3. + x_erasure_edges.push(x_edge); + z_erasure_edges.push(z_edge); + } + } + + // Phase 3: Augmented UF decode with erasures. + // X errors are decoded on Z graph (with Z erasures). + // Z errors are decoded on X graph (with X erasures). + let x_obs = self + .x_decoder + .decode_with_erasures(x_syndrome, &x_erasure_edges); + let z_obs = self + .z_decoder + .decode_with_erasures(z_syndrome, &z_erasure_edges); + + (x_obs, z_obs) + } +} + +impl pecos_decoder_core::ObservableDecoder for CssUfDecoder { + /// Decode a concatenated `[x_syndrome | z_syndrome]` via UIUF. + /// + /// 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 { + let split = self.x_num_detectors; + if syndrome.len() < split { + return Err(DecoderError::DecodingFailed(format!( + "CssUfDecoder: syndrome length {} < x_num_detectors {}", + syndrome.len(), + split + ))); + } + 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) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + // Minimal X-basis and Z-basis DEMs for a distance-3 repetition code. + const X_DEM: &str = "\ +error(0.01) D0 D1 L0 +error(0.01) D0 +error(0.01) D1 +"; + + const Z_DEM: &str = "\ +error(0.01) D0 D1 +error(0.01) D0 L0 +error(0.01) D1 +"; + + #[test] + fn test_css_construction() { + let dec = CssUfDecoder::from_dems(X_DEM, Z_DEM, UfDecoderConfig::default()); + assert!(dec.is_ok()); + } + + #[test] + fn test_css_no_errors() { + let mut dec = CssUfDecoder::from_dems(X_DEM, Z_DEM, UfDecoderConfig::default()).unwrap(); + let (x_obs, z_obs) = dec.decode_css(&[0, 0], &[0, 0]).unwrap(); + assert_eq!(x_obs, 0); + assert_eq!(z_obs, 0); + } + + #[test] + fn test_css_with_qubit_mapping() { + let mut dec = CssUfDecoder::from_dems(X_DEM, Z_DEM, UfDecoderConfig::default()).unwrap(); + + // Set up mapping: edge 0 in X_DEM corresponds to edge 0 in Z_DEM + // (same data qubit connecting the two detectors). + dec.set_qubit_mapping(QubitEdgeMapping { + pairs: vec![(0, 0)], + }); + + // No errors: should still decode correctly. + let (x_obs, z_obs) = dec.decode_css(&[0, 0], &[0, 0]).unwrap(); + assert_eq!(x_obs, 0); + assert_eq!(z_obs, 0); + + // Y-error scenario: both X and Z syndromes have the same defects. + // The intersection should identify the qubit as erasure. + let (x_obs, z_obs) = dec.decode_css(&[1, 1], &[1, 1]).unwrap(); + // With erasure, both decoders should handle this correctly. + // The exact observable depends on the DEM structure. + let _ = (x_obs, z_obs); // Just verify no panic. + } + + #[test] + fn test_css_independent_decoding() { + let mut dec = CssUfDecoder::from_dems(X_DEM, Z_DEM, UfDecoderConfig::default()).unwrap(); + + // X syndrome has defects, Z syndrome clean. + let (x_obs, z_obs) = dec.decode_css(&[1, 1], &[0, 0]).unwrap(); + assert_eq!(x_obs, 1); // D0-D1 edge carries L0 in X_DEM + assert_eq!(z_obs, 0); + + // Z syndrome has defects, X syndrome clean. + let (x_obs, z_obs) = dec.decode_css(&[0, 0], &[1, 1]).unwrap(); + assert_eq!(x_obs, 0); + assert_eq!(z_obs, 0); // D0-D1 edge has no observable in Z_DEM + } +} diff --git a/crates/pecos-uf-decoder/src/decoder.rs b/crates/pecos-uf-decoder/src/decoder.rs new file mode 100644 index 000000000..1ce43de0d --- /dev/null +++ b/crates/pecos-uf-decoder/src/decoder.rs @@ -0,0 +1,1306 @@ +// 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. + +//! Syndrome-graph Union-Find decoder implementation. +//! +//! The algorithm (Delfosse-Nickerson style): +//! +//! 1. Each defect detector starts as its own cluster (odd parity). +//! 2. Grow all unsatisfied clusters by radius until an edge becomes fusible. +//! An edge is fusible when the sum of endpoint radii reaches the edge weight. +//! Two growing clusters fuse at half the weight; boundary needs full weight. +//! 3. Fuse all fusible edges, merging clusters. Parity = XOR of components. +//! 4. Repeat until all clusters have even parity or contain the boundary. +//! 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. + +use pecos_decoder_core::correlated_decoder::MatchingDecoder; +use pecos_decoder_core::dem::DemMatchingGraph; +use pecos_decoder_core::errors::DecoderError; +use std::cmp::Reverse; +use std::collections::BinaryHeap; + +/// Edge in the syndrome graph. +#[derive(Debug, Clone)] +struct Edge { + /// First endpoint node index. + node1: u32, + /// Second endpoint node index (boundary = `num_detectors`). + node2: u32, + /// Weight (log-likelihood ratio). Lower = more likely error. + weight: f64, + /// Observable bitmask for this edge. + obs_mask: u64, +} + +/// Peeling strategy for correction extraction. +#[derive(Debug, Clone, Copy, Default)] +pub enum PeelingStrategy { + /// BFS from boundary. Fastest, slightly less accurate. + Bfs, + /// Prim's MST from boundary. Uses globally lightest edges. + /// Better accuracy, slight heap overhead at small sizes. + #[default] + PrimMst, +} + +/// Growth strategy for cluster expansion. +#[derive(Debug, Clone, Copy, Default)] +pub enum GrowthStrategy { + /// Event-driven with priority queue. Weighted growth (1/size). + /// Best for larger codes (d >= 7). O(E log E) total. + #[default] + EventDriven, + /// Scan-based: find min increment per round, grow all, fuse. + /// Lower constant overhead for small codes (d <= 5). + ScanBased, +} + +/// Configuration for the UF decoder. +/// +/// Use `UfDecoderConfig::fast()`, `::balanced()`, or `::accurate()` for +/// presets, then override individual fields as needed. +#[derive(Debug, Clone, Copy)] +pub struct UfDecoderConfig { + /// Maximum growth rounds before giving up (prevents infinite loops). + /// 0 = auto (100 * `num_detectors`). + pub max_growth_rounds: usize, + /// How to build the spanning forest for peeling. + pub peeling: PeelingStrategy, + /// How to grow clusters. + pub growth: GrowthStrategy, + /// Enable cluster predecoder for simple syndromes. + /// Disable for windowed decoding which needs complete edge tracking. + pub predecoder: bool, +} + +impl Default for UfDecoderConfig { + fn default() -> Self { + Self::fast() + } +} + +impl UfDecoderConfig { + /// Fast preset: event-driven growth, BFS peeling. Lowest latency. + #[must_use] + pub fn fast() -> Self { + Self { + max_growth_rounds: 0, + peeling: PeelingStrategy::Bfs, + growth: GrowthStrategy::EventDriven, + predecoder: true, + } + } + + /// Balanced preset: event-driven weighted growth, Prim MST peeling. + /// Better accuracy, used as inner decoder for two-pass correlated mode. + #[must_use] + pub fn balanced() -> Self { + Self { + max_growth_rounds: 0, + peeling: PeelingStrategy::PrimMst, + growth: GrowthStrategy::EventDriven, + predecoder: true, + } + } + + /// Accurate preset: same as balanced (UIUF accuracy comes from + /// the CSS wrapper, not from single-graph config). + #[must_use] + pub fn accurate() -> Self { + Self::balanced() + } + + /// Windowed preset: Prim MST peeling, no predecoder (need complete edge tracking). + #[must_use] + pub fn windowed() -> Self { + Self { + max_growth_rounds: 0, + peeling: PeelingStrategy::PrimMst, + growth: GrowthStrategy::EventDriven, + predecoder: false, + } + } +} + +/// Fast syndrome-graph Union-Find decoder. +pub struct UfDecoder { + /// Edges in the syndrome graph. + edges: Vec, + /// CSR adjacency: flat data array of (`edge_index`, `neighbor_node`). + adj_data: Vec<(usize, u32)>, + /// CSR adjacency: offset[i]..offset[i+1] is the range in `adj_data` for node i. + adj_offset: Vec, + /// Number of detectors. + num_detectors: usize, + /// Config. + config: UfDecoderConfig, + + // === Per-shot reusable buffers === + /// Disjoint-set forest: parent[i] = parent of node i. + parent: Vec, + /// Rank for union-by-rank. + rank: Vec, + /// Cluster parity: true = odd (needs correction). + parity: Vec, + /// Whether cluster contains the boundary node (satisfied regardless of parity). + has_boundary: Vec, + /// Growth radius of each cluster at `last_growth_time` (tracked at root). + radius: Vec, + /// Time when radius was last updated (for lazy computation). + last_growth_time: Vec, + /// Cluster size (number of nodes, tracked at root). + cluster_size: Vec, + /// Defect flags per detector. + is_defect: Vec, + + // === Scratch buffers (reused across shots to avoid allocation) === + /// Growth event queue. + growth_events: BinaryHeap>, + /// Peeling: tree parent for each node. + tree_parent: Vec>, + /// Peeling: visited flags. + visited: Vec, + /// Peeling: visit order for reverse traversal. + visit_order: Vec, + /// Peeling: priority queue for Prim's MST. + peel_heap: BinaryHeap>, + /// Peeling: subtree parity for each node. + subtree_parity: Vec, + /// Peeling: correction edge indices. + correction_edges: Vec, + /// Weight swap buffer for `decode_with_weights`. + weight_swap: Vec<(usize, f64)>, +} + +impl UfDecoder { + /// Get the adjacency entries for a node (slice into CSR data). + #[inline] + fn adj(&self, node: usize) -> &[(usize, u32)] { + let start = self.adj_offset[node] as usize; + let end = self.adj_offset[node + 1] as usize; + &self.adj_data[start..end] + } + + /// Build from a `DemMatchingGraph`. + #[must_use] + pub fn from_matching_graph(graph: &DemMatchingGraph, config: UfDecoderConfig) -> Self { + let num_detectors = graph.num_detectors; + let num_nodes = num_detectors + 1; + let boundary_node = num_detectors as u32; + + let mut edges = Vec::with_capacity(graph.edges.len()); + // Build temporary adjacency for sorting, then flatten to CSR. + let mut temp_adj: Vec> = vec![Vec::new(); num_nodes]; + + for (idx, me) in graph.edges.iter().enumerate() { + let n1 = me.node1; + let n2 = me.node2.map_or(boundary_node, |n| n); + + let mut obs_mask = 0u64; + for &o in &me.observables { + obs_mask |= 1 << o; + } + + edges.push(Edge { + node1: n1, + node2: n2, + weight: me.weight, + obs_mask, + }); + + temp_adj[n1 as usize].push((idx, n2)); + temp_adj[n2 as usize].push((idx, n1)); + } + + // Sort each node's adjacency by weight (lightest first). + for adj in &mut temp_adj { + adj.sort_by(|a, b| { + edges[a.0] + .weight + .partial_cmp(&edges[b.0].weight) + .unwrap_or(std::cmp::Ordering::Equal) + }); + } + + // Flatten to CSR format. + let total_entries: usize = temp_adj.iter().map(std::vec::Vec::len).sum(); + let mut adj_data = Vec::with_capacity(total_entries); + let mut adj_offset = Vec::with_capacity(num_nodes + 1); + for adj in &temp_adj { + adj_offset.push(adj_data.len() as u32); + adj_data.extend_from_slice(adj); + } + adj_offset.push(adj_data.len() as u32); + + Self { + edges, + adj_data, + adj_offset, + num_detectors, + config, + parent: vec![0; num_nodes], + rank: vec![0; num_nodes], + parity: vec![false; num_nodes], + has_boundary: vec![false; num_nodes], + radius: vec![0.0; num_nodes], + last_growth_time: vec![0.0; num_nodes], + cluster_size: vec![1; num_nodes], + is_defect: vec![false; num_nodes], + growth_events: BinaryHeap::new(), + tree_parent: vec![None; num_nodes], + visited: vec![false; num_nodes], + visit_order: Vec::with_capacity(num_nodes), + peel_heap: BinaryHeap::new(), + subtree_parity: vec![false; num_nodes], + correction_edges: Vec::new(), + weight_swap: Vec::new(), + } + } + + /// Build from a DEM string. + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed. + pub fn from_dem(dem: &str, config: UfDecoderConfig) -> Result { + let graph = DemMatchingGraph::from_dem_str(dem)?; + Ok(Self::from_matching_graph(&graph, config)) + } + + /// Reset per-shot state. Uses bulk fill operations for cache efficiency. + fn reset(&mut self) { + let boundary = self.num_detectors; + let n = boundary + 1; + // Bulk-fill each array (SIMD-friendly). + for i in 0..n { + self.parent[i] = i as u32; + } + self.rank[..n].fill(0); + self.parity[..n].fill(false); + self.has_boundary[..n].fill(false); + self.has_boundary[boundary] = true; + self.radius[..n].fill(0.0); + self.last_growth_time[..n].fill(0.0); + self.cluster_size[..n].fill(1); + self.is_defect[..n].fill(false); + } + + /// Find root of node with path halving (one shortcut per step). + fn find(&mut self, mut x: u32) -> u32 { + while self.parent[x as usize] != x { + let grandparent = self.parent[self.parent[x as usize] as usize]; + self.parent[x as usize] = grandparent; + x = grandparent; + } + x + } + + /// Union two clusters. Returns the new root. + fn union(&mut self, a: u32, b: u32) -> u32 { + let ra = self.find(a); + let rb = self.find(b); + if ra == rb { + return ra; + } + + // Union by rank + let (root, child) = if self.rank[ra as usize] >= self.rank[rb as usize] { + (ra, rb) + } else { + (rb, ra) + }; + + self.parent[child as usize] = root; + if self.rank[root as usize] == self.rank[child as usize] { + self.rank[root as usize] += 1; + } + + // XOR parities + self.parity[root as usize] ^= self.parity[child as usize]; + // Propagate boundary membership + self.has_boundary[root as usize] |= self.has_boundary[child as usize]; + // Keep the larger radius + self.radius[root as usize] = self.radius[root as usize].max(self.radius[child as usize]); + // Sum cluster sizes + self.cluster_size[root as usize] += self.cluster_size[child as usize]; + + root + } + + /// Decode a syndrome and return the observable mask. + pub fn decode_syndrome(&mut self, syndrome: &[u8]) -> u64 { + // Try cluster-detection predecoder (if enabled). + if self.config.predecoder + && let Some(obs) = self.predecode_clusters(syndrome) + { + return obs; + } + + // Full decoder path for complex syndromes. + 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(); + self.peel_correction() + } + + /// Cluster-detection predecoder. + /// + /// Finds connected components of defects in the matching graph. + /// - Size-0 components: no defects, return 0. + /// - Size-1 components: match to boundary. + /// - Size-2 components (adjacent pair): match directly if their edge + /// is lighter than both boundary alternatives. + /// - Size 3+: too complex, fall through to full UF. + /// + /// This is provably correct: isolated clusters are independent, so + /// predecoding them individually gives the same result as joint decoding. + #[must_use] + pub fn predecode_clusters(&self, syndrome: &[u8]) -> Option { + let boundary = self.num_detectors as u32; + + // Mark defects. + // Use is_defect buffer conceptually but don't mutate self. + // Instead use a local bitset for small codes. + let mut defect_flags = vec![false; self.num_detectors]; + let mut defect_list: Vec = Vec::new(); + for (i, &v) in syndrome.iter().enumerate() { + if v != 0 && i < self.num_detectors { + defect_flags[i] = true; + defect_list.push(i as u32); + } + } + + if defect_list.is_empty() { + return Some(0); + } + + // Find connected components of defects. + // Two defects are connected if they share an edge in the matching graph. + // Use union-find on defect indices (not the full graph -- just defects). + let n = defect_list.len(); + let mut component: Vec = (0..n).collect(); // parent array + + // For each defect, check if any neighbor is also a defect. + for (di, &d) in defect_list.iter().enumerate() { + for &(_, neighbor) in self.adj(d as usize) { + if neighbor != boundary + && (neighbor as usize) < self.num_detectors + && defect_flags[neighbor as usize] + { + // Find the other defect's index in defect_list. + if let Some(ni) = defect_list.iter().position(|&x| x == neighbor) { + // Union di and ni. + let mut ra = di; + while component[ra] != ra { + ra = component[ra]; + } + let mut rb = ni; + while component[rb] != rb { + rb = component[rb]; + } + if ra != rb { + component[rb] = ra; + } + } + } + } + } + + // Flatten components. + for i in 0..n { + let mut r = i; + while component[r] != r { + r = component[r]; + } + component[i] = r; + } + + // Count component sizes. + let mut comp_size: Vec = vec![0; n]; + for &c in &component { + comp_size[c] += 1; + } + + // Check if any component has 3+ defects -- if so, fall through. + for &s in &comp_size { + if s >= 3 { + return None; // Complex cluster, need full UF. + } + } + + // All components are size 1 or 2. Predecode each. + let mut obs_mask = 0u64; + let mut handled = vec![false; n]; + + for di in 0..n { + if handled[di] { + continue; + } + let root = component[di]; + + if comp_size[root] == 1 { + // Isolated defect: match to boundary. + obs_mask ^= self.predecode_single(defect_list[di]); + handled[di] = true; + } else if comp_size[root] == 2 { + // Find the other defect in this component. + let mut ni = None; + for (dj, &candidate_root) in component.iter().enumerate().take(n).skip(di + 1) { + if candidate_root == root { + ni = Some(dj); + break; + } + } + let ni = ni?; + + 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; + } + } + + 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 { + 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; + } + } + // 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. + fn is_unsatisfied(&self, root: usize) -> bool { + self.parity[root] && !self.has_boundary[root] + } + + /// Compute the growth rate for a cluster: `1 / size(cluster)`. + /// Smaller clusters grow faster, producing better pairings. + fn growth_rate(&self, root: usize) -> f64 { + 1.0 / f64::from(self.cluster_size[root]) + } + + /// Get the effective radius of a cluster at a given time. + /// Uses lazy computation: radius is only updated when queried. + fn effective_radius(&self, root: usize, current_time: f64) -> f64 { + if self.is_unsatisfied(root) && root != self.num_detectors { + let dt = current_time - self.last_growth_time[root]; + self.radius[root] + dt * self.growth_rate(root) + } else { + self.radius[root] + } + } + + /// Materialize the lazy radius for a cluster (update stored value). + fn materialize_radius(&mut self, root: usize, current_time: f64) { + if self.is_unsatisfied(root) && root != self.num_detectors { + let dt = current_time - self.last_growth_time[root]; + self.radius[root] += dt * self.growth_rate(root); + } + self.last_growth_time[root] = current_time; + } + + /// Compute when edge becomes fusible given current radii and growth rates. + /// Returns the absolute time, or 0 if already fusible. + fn fusible_time(&self, root_u: usize, root_v: usize, weight: f64, current_time: f64) -> f64 { + let r_u = self.effective_radius(root_u, current_time); + let r_v = self.effective_radius(root_v, current_time); + let gap = weight - r_u - r_v; + if gap <= 0.0 { + return current_time; + } + + let u_grows = self.is_unsatisfied(root_u); + let v_grows = self.is_unsatisfied(root_v); + + let combined_rate = if u_grows && v_grows { + self.growth_rate(root_u) + self.growth_rate(root_v) + } else if u_grows { + self.growth_rate(root_u) + } else if v_grows { + self.growth_rate(root_v) + } else { + return f64::INFINITY; // neither grows + }; + + current_time + gap / combined_rate + } + + /// Dispatch to the configured growth strategy. + fn grow_clusters(&mut self) { + match self.config.growth { + GrowthStrategy::EventDriven => self.grow_event_driven(), + GrowthStrategy::ScanBased => self.grow_scan_based(), + } + } + + /// Scan-based growth: simple loop, lower overhead for small codes. + /// + /// Each round: scan all cross-cluster edges to find the minimum growth + /// increment, grow all unsatisfied clusters uniformly, then fuse. + /// O(R * E) where R is the number of growth rounds. + fn grow_scan_based(&mut self) { + let boundary = self.num_detectors; + let max_rounds = if self.config.max_growth_rounds > 0 { + self.config.max_growth_rounds + } else { + 100 * self.num_detectors.max(1) + }; + + for _round in 0..max_rounds { + // Check for any unsatisfied cluster. + let mut any_unsatisfied = false; + for i in 0..=self.num_detectors { + let root = self.find(i as u32) as usize; + if root == i && self.is_unsatisfied(root) { + any_unsatisfied = true; + break; + } + } + if !any_unsatisfied { + break; + } + + // Find the minimum growth increment across all cross-cluster edges. + let mut min_increment = f64::INFINITY; + for node in 0..=self.num_detectors { + let root_u = self.find(node as u32) as usize; + if !self.is_unsatisfied(root_u) { + continue; + } + + let adj_len = self.adj(node).len(); + for adj_i in 0..adj_len { + let (edge_idx, neighbor) = self.adj(node)[adj_i]; + let root_v = self.find(neighbor) as usize; + if root_u == root_v { + continue; + } + + let w = self.edges[edge_idx].weight; + let gap = w - self.radius[root_u] - self.radius[root_v]; + if gap <= 0.0 { + min_increment = 0.0; + break; + } + + let v_grows = self.is_unsatisfied(root_v); + let needed = if v_grows { gap / 2.0 } else { gap }; + min_increment = min_increment.min(needed); + } + if min_increment == 0.0 { + break; + } + } + + if min_increment.is_infinite() { + break; + } + + // Grow all unsatisfied clusters. + for i in 0..=self.num_detectors { + let root = self.find(i as u32) as usize; + if root == i && self.is_unsatisfied(root) && i != boundary { + self.radius[root] += min_increment; + } + } + + // Fuse all now-fusible cross-cluster edges. + // Collect first to avoid borrow issues. + let mut fuse_count = 0; + for node in 0..=self.num_detectors { + let adj_len = self.adj(node).len(); + for adj_i in 0..adj_len { + let (_, neighbor) = self.adj(node)[adj_i]; + let root_u = self.find(node as u32) as usize; + let root_v = self.find(neighbor) as usize; + if root_u == root_v { + continue; + } + let w = self.edges[self.adj(node)[adj_i].0].weight; + if self.radius[root_u] + self.radius[root_v] >= w - 1e-12 { + self.union(node as u32, neighbor); + fuse_count += 1; + } + } + } + if fuse_count == 0 && min_increment == 0.0 { + break; // No progress + } + } + } + + /// Event-driven weighted cluster growth. + /// + /// Smaller clusters grow faster (rate = 1/size), making nearby small + /// clusters fuse before large clusters can absorb them. This improves + /// the quality of the UF pairings. + /// + /// Uses a priority queue with lazy deletion for O(E log E) total work. + fn grow_event_driven(&mut self) { + self.growth_events.clear(); + + // Seed events only from defect nodes (unsatisfied singletons). + // At low error rates, this skips ~95% of nodes. + for node in 0..self.num_detectors { + if !self.is_defect[node] { + continue; + } + + let adj_len = self.adj(node).len(); + for adj_i in 0..adj_len { + let (edge_idx, neighbor) = self.adj(node)[adj_i]; + let root_v = self.find(neighbor) as usize; + if root_v == node { + continue; // same cluster (shouldn't happen for singletons) + } + + let ft = self.fusible_time(node, root_v, self.edges[edge_idx].weight, 0.0); + if ft.is_finite() { + self.growth_events.push(Reverse(( + ft.to_bits(), + edge_idx, + node as u32, + neighbor, + ))); + } + } + } + + let mut current_time: f64; + let max_events = if self.config.max_growth_rounds > 0 { + self.config.max_growth_rounds + } else { + 1000 * self.edges.len().max(1) + }; + let mut events_processed = 0; + + while let Some(Reverse((time_bits, _edge_idx, a, b))) = self.growth_events.pop() { + events_processed += 1; + if events_processed > max_events { + break; + } + let event_time = f64::from_bits(time_bits); + + let root_a = self.find(a) as usize; + let root_b = self.find(b) as usize; + if root_a == root_b { + continue; + } + + let a_unsat = self.is_unsatisfied(root_a); + let b_unsat = self.is_unsatisfied(root_b); + if !a_unsat && !b_unsat { + continue; + } + + current_time = event_time; + + // Materialize radii for the merging clusters (lazy update). + self.materialize_radius(root_a, current_time); + self.materialize_radius(root_b, current_time); + + // Fuse. + let new_root = self.union(a, b) as usize; + self.last_growth_time[new_root] = current_time; + + if !self.is_unsatisfied(new_root) { + continue; + } + + // Re-insert events for edges from the merge nodes with updated times. + for &node in &[a, b] { + let nu = node as usize; + let adj_len = self.adj(nu).len(); + for adj_i in 0..adj_len { + let (edge_idx, neighbor) = self.adj(nu)[adj_i]; + let root_n = self.find(neighbor) as usize; + if root_n == new_root { + continue; + } + + let ft = self.fusible_time( + new_root, + root_n, + self.edges[edge_idx].weight, + current_time, + ); + if ft.is_finite() { + self.growth_events + .push(Reverse((ft.to_bits(), edge_idx, node, neighbor))); + } + } + } + } + } + + /// Build a spanning forest and peel to extract the correction. + /// Returns `(obs_mask, correction_edge_indices)`. + fn peel_correction_with_edges(&mut self) -> (u64, Vec) { + match self.config.peeling { + PeelingStrategy::PrimMst => self.peel_prim_mst(), + PeelingStrategy::Bfs => self.peel_bfs(), + } + } + + /// Prim's MST peeling: globally lightest spanning tree. Better accuracy. + fn peel_prim_mst(&mut self) -> (u64, Vec) { + let boundary = self.num_detectors; + + self.tree_parent.fill(None); + self.visited.fill(false); + self.visit_order.clear(); + self.peel_heap.clear(); + self.correction_edges.clear(); + + for seed in std::iter::once(boundary).chain(0..self.num_detectors) { + if self.visited[seed] { + continue; + } + self.visited[seed] = true; + self.visit_order.push(seed as u32); + + let adj_len = self.adj(seed).len(); + for adj_i in 0..adj_len { + let (edge_idx, neighbor) = self.adj(seed)[adj_i]; + if !self.visited[neighbor as usize] { + let w_bits = self.edges[edge_idx].weight.to_bits(); + self.peel_heap + .push(Reverse((w_bits, edge_idx, seed as u32, neighbor))); + } + } + + while let Some(Reverse((_w_bits, edge_idx, from, to))) = self.peel_heap.pop() { + let tu = to as usize; + if self.visited[tu] { + continue; + } + let from_root = self.find(from); + let to_root = self.find(to); + if from_root != to_root { + continue; + } + self.visited[tu] = true; + self.tree_parent[tu] = Some((from, edge_idx)); + self.visit_order.push(to); + + let adj_len = self.adj(tu).len(); + for adj_i in 0..adj_len { + let (e_idx, nbr) = self.adj(tu)[adj_i]; + if !self.visited[nbr as usize] { + let w_bits = self.edges[e_idx].weight.to_bits(); + self.peel_heap.push(Reverse((w_bits, e_idx, to, nbr))); + } + } + } + } + + // Peel: process in reverse visit order (leaves first). + self.subtree_parity.fill(false); + self.subtree_parity[..self.num_detectors] + .copy_from_slice(&self.is_defect[..self.num_detectors]); + + let mut obs_mask = 0u64; + + for i in (0..self.visit_order.len()).rev() { + let v = self.visit_order[i]; + if let Some((parent, edge_idx)) = self.tree_parent[v as usize] { + if self.subtree_parity[v as usize] { + obs_mask ^= self.edges[edge_idx].obs_mask; + self.correction_edges.push(edge_idx); + } + self.subtree_parity[parent as usize] ^= self.subtree_parity[v as usize]; + } + } + + (obs_mask, self.correction_edges.clone()) + } + + /// BFS peeling: simpler, faster (no heap), slightly less accurate. + fn peel_bfs(&mut self) -> (u64, Vec) { + let boundary = self.num_detectors; + + self.tree_parent.fill(None); + self.visited.fill(false); + self.visit_order.clear(); + self.correction_edges.clear(); + + for seed in std::iter::once(boundary).chain(0..self.num_detectors) { + if self.visited[seed] { + continue; + } + self.visited[seed] = true; + self.visit_order.push(seed as u32); + + let mut queue_start = self.visit_order.len() - 1; + while queue_start < self.visit_order.len() { + let v = self.visit_order[queue_start] as usize; + queue_start += 1; + + let adj_len = self.adj(v).len(); + for adj_i in 0..adj_len { + let (edge_idx, neighbor) = self.adj(v)[adj_i]; + let nu = neighbor as usize; + if self.visited[nu] { + continue; + } + let v_root = self.find(v as u32); + let n_root = self.find(neighbor); + if v_root != n_root { + continue; + } + self.visited[nu] = true; + self.tree_parent[nu] = Some((v as u32, edge_idx)); + self.visit_order.push(neighbor); + } + } + } + + self.subtree_parity.fill(false); + self.subtree_parity[..self.num_detectors] + .copy_from_slice(&self.is_defect[..self.num_detectors]); + + let mut obs_mask = 0u64; + for i in (0..self.visit_order.len()).rev() { + let v = self.visit_order[i]; + if let Some((parent, edge_idx)) = self.tree_parent[v as usize] { + if self.subtree_parity[v as usize] { + obs_mask ^= self.edges[edge_idx].obs_mask; + self.correction_edges.push(edge_idx); + } + self.subtree_parity[parent as usize] ^= self.subtree_parity[v as usize]; + } + } + + (obs_mask, self.correction_edges.clone()) + } + + /// Peel correction, returning only the observable mask. + fn peel_correction(&mut self) -> u64 { + self.peel_correction_with_edges().0 + } + + /// Number of edges in the matching graph. + #[must_use] + pub fn num_edges(&self) -> usize { + self.edges.len() + } + + /// Number of detectors. + #[must_use] + pub fn num_detectors(&self) -> usize { + self.num_detectors + } + + /// Get the observable mask for an edge. + #[must_use] + pub fn edge_obs_mask(&self, edge_idx: usize) -> u64 { + self.edges.get(edge_idx).map_or(0, |e| e.obs_mask) + } + + /// Get node1 of an edge. + #[must_use] + pub fn edge_node1(&self, edge_idx: usize) -> u32 { + self.edges.get(edge_idx).map_or(0, |e| e.node1) + } + + /// Get node2 of an edge (boundary = `num_detectors`). + #[must_use] + pub fn edge_node2(&self, edge_idx: usize) -> u32 { + self.edges.get(edge_idx).map_or(0, |e| e.node2) + } + + /// Get the weight of an edge (log-likelihood ratio). + #[must_use] + pub fn edge_weight(&self, edge_idx: usize) -> f64 { + self.edges.get(edge_idx).map_or(0.0, |e| e.weight) + } + + /// Decode with full UF (no predecoder) and return matched edges. + /// Used by windowed decoder which needs complete edge tracking. + /// + /// # Errors + /// + /// Returns `DecoderError` if decoding fails. + pub fn decode_full_matching( + &mut self, + syndrome: &[u8], + ) -> Result<(u64, Vec), DecoderError> { + 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(); + Ok(self.peel_correction_with_edges()) + } + + // === UIUF support methods === + + /// Run syndrome validation (growth phase only, no peeling). + /// + /// After calling this, the internal cluster state reflects which nodes + /// have been merged. Use `edge_in_cluster()` to query which edges are + /// covered by clusters. + pub fn syndrome_validate(&mut self, syndrome: &[u8]) { + 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(); + } + + /// Check if an edge's two endpoints are in the same cluster. + /// + /// Call after `syndrome_validate()`. Returns true if the edge is + /// "covered" by a cluster (both endpoints merged into one component). + pub fn edge_in_cluster(&mut self, edge_idx: usize) -> bool { + if edge_idx >= self.edges.len() { + return false; + } + let n1 = self.edges[edge_idx].node1; + let n2 = self.edges[edge_idx].node2; + let root_a = self.find(n1); + let root_b = self.find(n2); + root_a == root_b + } + + /// Decode with pre-seeded erasure edges. + /// + /// Erasure edges are pre-merged into clusters before growth begins. + /// This is used by UIUF Phase 3 after the intersection step identifies + /// likely Y errors as erasures. + pub fn decode_with_erasures(&mut self, syndrome: &[u8], erasure_edges: &[usize]) -> u64 { + 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; + } + } + + // Pre-merge erasure edges before growth. + for &edge_idx in erasure_edges { + if edge_idx < self.edges.len() { + let n1 = self.edges[edge_idx].node1; + let n2 = self.edges[edge_idx].node2; + self.union(n1, n2); + } + } + + self.grow_clusters(); + self.peel_correction() + } +} + +// === Trait implementations === + +impl pecos_decoder_core::ObservableDecoder for UfDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + Ok(self.decode_syndrome(syndrome)) + } +} + +impl pecos_decoder_core::correlated_decoder::MatchingDecoder for UfDecoder { + fn decode_with_matching(&mut self, syndrome: &[u8]) -> Result<(u64, Vec), DecoderError> { + // Count defects for predecoder. + let num_defects = syndrome + .iter() + .take(self.num_detectors) + .filter(|&&v| v != 0) + .count() as u32; + + if num_defects == 0 { + return Ok((0, Vec::new())); + } + + // Cluster predecoder (if enabled). Skipped in windowed mode + // because windowed decoding needs complete edge tracking. + if self.config.predecoder + && let Some(obs) = self.predecode_clusters(syndrome) + { + return Ok((obs, Vec::new())); + } + + // Full decode path. + 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(); + Ok(self.peel_correction_with_edges()) + } + + fn decode_with_weights( + &mut self, + syndrome: &[u8], + weights: &[f64], + ) -> Result<(u64, Vec), DecoderError> { + // Temporarily swap in the new weights + self.weight_swap.clear(); + for (i, &w) in weights.iter().enumerate() { + if i < self.edges.len() { + self.weight_swap.push((i, self.edges[i].weight)); + self.edges[i].weight = w; + } + } + + // Note: CSR adjacency sort order is fixed at construction. + // The weight swap affects growth event ordering but not correctness. + let result = self.decode_with_matching(syndrome); + + // Restore original weights + for &(i, w) in &self.weight_swap { + self.edges[i].weight = w; + } + + result + } + + fn num_edges(&self) -> usize { + self.edges.len() + } +} + +impl pecos_decoder_core::correlated_decoder::EdgeTrackingDecoder for UfDecoder { + fn edge_node1(&self, edge_idx: usize) -> u32 { + self.edges.get(edge_idx).map_or(0, |e| e.node1) + } + + fn edge_node2(&self, edge_idx: usize) -> u32 { + self.edges.get(edge_idx).map_or(0, |e| e.node2) + } + + fn edge_weight(&self, edge_idx: usize) -> f64 { + self.edges.get(edge_idx).map_or(0.0, |e| e.weight) + } + + fn edge_obs_mask(&self, edge_idx: usize) -> u64 { + self.edges.get(edge_idx).map_or(0, |e| e.obs_mask) + } + + fn num_detectors(&self) -> usize { + self.num_detectors + } +} + +impl pecos_decoder_core::erasure::ObservableErasureDecoder for UfDecoder { + fn decode_with_erasures( + &mut self, + syndrome: &[u8], + erasure_edges: &[usize], + ) -> Result { + if erasure_edges.is_empty() { + // Use MatchingDecoder path directly. + use pecos_decoder_core::correlated_decoder::MatchingDecoder; + let (obs, _) = self.decode_with_matching(syndrome)?; + return Ok(obs); + } + + // Set erased edges to weight=0 (certain error), decode, restore. + let mut modified_weights = Vec::with_capacity(self.edges.len()); + for (i, e) in self.edges.iter().enumerate() { + if erasure_edges.contains(&i) { + modified_weights.push(0.0); + } else { + modified_weights.push(e.weight); + } + } + + let (obs, _) = self.decode_with_weights(syndrome, &modified_weights)?; + Ok(obs) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const SIMPLE_DEM: &str = "\ +error(0.1) D0 D1 L0 +error(0.1) D1 +"; + + const SURFACE_LIKE_DEM: &str = "\ +error(0.01) D0 D1 L0 +error(0.01) D1 D2 +error(0.01) D2 +error(0.01) D0 +"; + + #[test] + fn test_no_errors() { + let mut dec = UfDecoder::from_dem(SIMPLE_DEM, UfDecoderConfig::default()).unwrap(); + assert_eq!(dec.decode_syndrome(&[0, 0]), 0); + } + + #[test] + fn test_single_error() { + let mut dec = UfDecoder::from_dem(SIMPLE_DEM, UfDecoderConfig::default()).unwrap(); + // D0 and D1 both triggered -> edge D0-D1 (carries L0) + assert_eq!(dec.decode_syndrome(&[1, 1]), 1); + } + + #[test] + fn test_boundary_error() { + let mut dec = UfDecoder::from_dem(SIMPLE_DEM, UfDecoderConfig::default()).unwrap(); + // Only D1 triggered -> boundary edge (no observable) + assert_eq!(dec.decode_syndrome(&[0, 1]), 0); + } + + #[test] + fn test_multiple_shots() { + let mut dec = UfDecoder::from_dem(SIMPLE_DEM, UfDecoderConfig::default()).unwrap(); + for _ in 0..20 { + let _ = dec.decode_syndrome(&[1, 1]); + let _ = dec.decode_syndrome(&[0, 1]); + let _ = dec.decode_syndrome(&[0, 0]); + } + } + + #[test] + fn test_surface_like() { + let mut dec = UfDecoder::from_dem(SURFACE_LIKE_DEM, UfDecoderConfig::default()).unwrap(); + // D0 triggered -> boundary edge (no observable) + assert_eq!(dec.decode_syndrome(&[1, 0, 0]), 0); + // D0 and D1 -> edge D0-D1 (L0) + assert_eq!(dec.decode_syndrome(&[1, 1, 0]), 1); + } + + #[test] + fn test_observable_decoder_trait() { + use pecos_decoder_core::ObservableDecoder; + let mut dec = UfDecoder::from_dem(SIMPLE_DEM, UfDecoderConfig::default()).unwrap(); + let mask = dec.decode_to_observables(&[1, 1]).unwrap(); + assert_eq!(mask, 1); + } + + #[test] + fn test_matching_decoder_trait() { + use pecos_decoder_core::correlated_decoder::MatchingDecoder; + let mut dec = UfDecoder::from_dem(SIMPLE_DEM, UfDecoderConfig::default()).unwrap(); + let (mask, _edges) = dec.decode_with_matching(&[1, 1]).unwrap(); + assert_eq!(mask, 1); + // Note: predecoder may return empty edges for simple cases. + } + + /// Distance-3 repetition code: 3 data qubits, 2 detectors, 2 rounds. + /// Tests a more realistic graph structure with time-like edges. + const REP_CODE_D3_DEM: &str = "\ +error(0.01) D0 D1 +error(0.01) D0 L0 +error(0.01) D1 +error(0.001) D0 D2 +error(0.001) D1 D3 +error(0.01) D2 D3 +error(0.01) D2 L0 +error(0.01) D3 +"; + + #[test] + fn test_rep_code_d3() { + let mut dec = UfDecoder::from_dem(REP_CODE_D3_DEM, UfDecoderConfig::default()).unwrap(); + assert_eq!(dec.num_edges(), 8); + assert_eq!(dec.num_detectors(), 4); + + // No defects + assert_eq!(dec.decode_syndrome(&[0, 0, 0, 0]), 0); + + // Single data error in round 1: D0 and D1 both fire + assert_eq!(dec.decode_syndrome(&[1, 1, 0, 0]), 0); + + // Boundary error: only D0 + assert_eq!(dec.decode_syndrome(&[1, 0, 0, 0]), 1); // L0 + + // Single boundary error round 2: only D2 + assert_eq!(dec.decode_syndrome(&[0, 0, 1, 0]), 1); // L0 + } + + #[test] + fn test_stress_reuse() { + // Verify buffers are correctly reused over many shots + let mut dec = UfDecoder::from_dem(REP_CODE_D3_DEM, UfDecoderConfig::default()).unwrap(); + let syndromes: &[&[u8]] = &[ + &[0, 0, 0, 0], + &[1, 1, 0, 0], + &[1, 0, 0, 0], + &[0, 1, 0, 0], + &[0, 0, 1, 1], + &[1, 0, 1, 0], + ]; + for _ in 0..1000 { + for syn in syndromes { + let _ = dec.decode_syndrome(syn); + } + } + } +} diff --git a/crates/pecos-uf-decoder/src/lib.rs b/crates/pecos-uf-decoder/src/lib.rs new file mode 100644 index 000000000..180bccf56 --- /dev/null +++ b/crates/pecos-uf-decoder/src/lib.rs @@ -0,0 +1,51 @@ +// 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. + +//! Fast syndrome-graph Union-Find decoder. +//! +//! Purpose-built for surface codes and other QEC codes with matching-graph +//! structure. Works on the syndrome graph (not the Tanner graph), where nodes +//! are detectors and edges are error mechanisms. +//! +//! Design goals: +//! - Zero per-shot allocation (reusable flat arrays) +//! - No locks, no Arc, no hash sets (unlike MWPF's UF) +//! - Bounded worst-case latency +//! - Implements `ObservableDecoder` and `MatchingDecoder` for composability +//! with `TwoPassDecoder` (correlated decoding) + +#![allow( + clippy::cast_possible_truncation, + clippy::cast_precision_loss, + clippy::cast_sign_loss +)] + +pub mod astar; +pub mod bp_uf; +pub mod css_decoder; +pub mod decoder; +pub mod mini_bp; + +pub mod windowed; + +// Note: belief_matching (BP → PyMatching MWPM) lives in the Python bindings +// (fault_tolerance_bindings.rs) since it requires pecos-pymatching which is +// a C++ FFI crate. This crate stays pure Rust. + +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 windowed::{ + BeamSearchConfig, BeamSearchWindowedDecoder, OverlappingWindowedDecoder, + SandwichWindowedDecoder, StreamingWindowedDecoder, WindowedConfig, WindowedDecoder, +}; diff --git a/crates/pecos-uf-decoder/src/mini_bp.rs b/crates/pecos-uf-decoder/src/mini_bp.rs new file mode 100644 index 000000000..fd6510c36 --- /dev/null +++ b/crates/pecos-uf-decoder/src/mini_bp.rs @@ -0,0 +1,497 @@ +// 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. + +//! Minimal min-sum belief propagation for BP+UF hybrid decoding. +//! +//! Runs a few iterations of min-sum BP on the check matrix (Tanner graph) +//! to produce per-mechanism soft reliability scores. These scores are then +//! used to adjust UF edge weights for better accuracy. +//! +//! This is intentionally minimal -- no normalization, no scheduling tricks. +//! Just enough BP to extract useful soft information for UF. + +use pecos_decoder_core::dem::DemCheckMatrix; + +/// Pre-computed sparse structure for BP message passing. +/// Build once at construction time, reuse across shots. +/// Uses CSR-style flat arrays for cache-friendly iteration. +pub struct BpGraph { + pub num_checks: usize, + pub num_vars: usize, + pub prior_llr: Vec, + /// CSR for checks: flat data of (`var_idx`, `msg_array_idx`). + check_data: Vec<(u32, u32)>, + /// CSR offsets for checks: `check_offset`[c]..`check_offset`[c+1]. + check_offset: Vec, + /// CSR for vars: flat data of (`check_idx`, `msg_array_idx`). + var_data: Vec<(u32, u32)>, + /// CSR offsets for vars: `var_offset`[v]..`var_offset`[v+1]. + var_offset: Vec, + /// Total number of edges in the Tanner graph. + pub total_edges: usize, +} + +impl BpGraph { + /// Get check entries (CSR slice). + #[inline] + #[must_use] + pub fn check_entries(&self, c: usize) -> &[(u32, u32)] { + let s = self.check_offset[c] as usize; + let e = self.check_offset[c + 1] as usize; + &self.check_data[s..e] + } + + /// Get var entries (CSR slice). + #[inline] + fn var_entries(&self, v: usize) -> &[(u32, u32)] { + let s = self.var_offset[v] as usize; + let e = self.var_offset[v + 1] as usize; + &self.var_data[s..e] + } + + /// Build from a `DemCheckMatrix`. + #[must_use] + pub fn from_dcm(dcm: &DemCheckMatrix) -> Self { + let num_checks = dcm.num_detectors; + let num_vars = dcm.num_mechanisms; + + let prior_llr: Vec = dcm + .error_priors + .iter() + .map(|&p| { + if p <= 0.0 { + 30.0 + } else if p >= 1.0 { + -30.0 + } else { + ((1.0 - p) / p).ln() + } + }) + .collect(); + + // Build temporary adjacency then flatten to CSR. + let mut temp_check: Vec> = vec![Vec::new(); num_checks]; + let mut temp_var: Vec> = vec![Vec::new(); num_vars]; + let mut msg_idx: u32 = 0; + + for (c, check_entries) in temp_check.iter_mut().enumerate().take(num_checks) { + for (v, var_entries) in temp_var.iter_mut().enumerate().take(num_vars) { + if dcm.check_matrix[[c, v]] != 0 { + check_entries.push((v as u32, msg_idx)); + var_entries.push((c as u32, msg_idx)); + msg_idx += 1; + } + } + } + + // Flatten check entries. + let mut check_data = Vec::new(); + let mut check_offset = Vec::with_capacity(num_checks + 1); + for entries in &temp_check { + check_offset.push(check_data.len() as u32); + check_data.extend_from_slice(entries); + } + check_offset.push(check_data.len() as u32); + + // Flatten var entries. + let mut var_data = Vec::new(); + let mut var_offset = Vec::with_capacity(num_vars + 1); + for entries in &temp_var { + var_offset.push(var_data.len() as u32); + var_data.extend_from_slice(entries); + } + var_offset.push(var_data.len() as u32); + + Self { + num_checks, + num_vars, + prior_llr, + check_data, + check_offset, + var_data, + var_offset, + total_edges: msg_idx as usize, + } + } +} + +/// Run min-sum BP on a pre-computed graph and return posterior LLRs per mechanism. +/// +/// - `graph`: pre-computed sparse BP graph +/// - `syndrome`: detection events (1 = triggered) +/// - `num_iterations`: number of BP iterations +/// - `min_sum_scale`: scaling factor for min-sum messages (0.625 is standard) +/// - `serial`: if true, use serial schedule (better convergence, slower) +/// - `c_to_v`, `v_to_c`: reusable message buffers (must be `graph.total_edges` long) +/// - `posterior`: output buffer (must be `graph.num_vars` long) +#[allow(clippy::too_many_arguments)] // Hot-path helper takes reusable buffers explicitly. +pub fn min_sum_bp_into( + graph: &BpGraph, + syndrome: &[u8], + num_iterations: usize, + min_sum_scale: f64, + serial: bool, + c_to_v: &mut [f64], + v_to_c: &mut [f64], + posterior: &mut Vec, +) { + let num_checks = graph.num_checks; + let num_vars = graph.num_vars; + + // Initialize v→c with priors. + for v in 0..num_vars { + for &(_c, idx) in graph.var_entries(v) { + v_to_c[idx as usize] = graph.prior_llr[v]; + } + } + + // Pre-compute syndrome signs (avoid branch in inner loop). + let mut syn_sign = vec![1.0f64; num_checks]; + for (c, sign) in syn_sign.iter_mut().enumerate() { + if c < syndrome.len() && syndrome[c] != 0 { + *sign = -1.0; + } + } + + let damp = 0.25; + + // EWA posterior accumulator. + let ewa_weight = 0.3; + let mut ewa_posterior = vec![0.0f64; num_vars]; + ewa_posterior.copy_from_slice(&graph.prior_llr); + + // EWAInit: run BP multiple times, using EWA of previous posteriors + // as the prior for the next run. This finds better fixed points. + let outer_iterations = if num_iterations >= 6 { 2 } else { 1 }; + let inner_iterations = if outer_iterations > 1 { + num_iterations / outer_iterations + } else { + num_iterations + }; + + for outer in 0..outer_iterations { + // Re-initialize v→c with current EWA posteriors as priors. + if outer > 0 { + for (v, &prior) in ewa_posterior.iter().enumerate().take(num_vars) { + for &(_c, idx) in graph.var_entries(v) { + v_to_c[idx as usize] = prior; + } + } + c_to_v.fill(0.0); + } + + for iter in 0..inner_iterations { + for (c, &syndrome_sign) in syn_sign.iter().enumerate().take(num_checks) { + let entries = graph.check_entries(c); + if entries.len() < 2 { + continue; + } + + // Check-to-variable (normalized min-sum). + let mut total_sign = syndrome_sign; + let mut min1 = f64::INFINITY; + let mut min2 = f64::INFINITY; + let mut min1_pos = usize::MAX; + + for (pos, &(_v, idx)) in entries.iter().enumerate() { + let msg = v_to_c[idx as usize]; + if msg < 0.0 { + total_sign = -total_sign; + } + let abs_msg = msg.abs(); + if abs_msg < min1 { + min2 = min1; + min1 = abs_msg; + min1_pos = pos; + } else if abs_msg < min2 { + min2 = abs_msg; + } + } + + for (pos, &(_v, idx)) in entries.iter().enumerate() { + let msg_v = v_to_c[idx as usize]; + let sign_without_v = total_sign.copysign(total_sign * msg_v); + let min_without_v = if pos == min1_pos { min2 } else { min1 }; + c_to_v[idx as usize] = sign_without_v * min_without_v * min_sum_scale; + } + + if serial { + // Serial: immediately update v→c for connected variables. + for &(v_idx, _) in entries { + let v = v_idx as usize; + let gamma = damp; + let v_entries = graph.var_entries(v); + let total: f64 = v_entries + .iter() + .map(|&(_c2, idx2)| c_to_v[idx2 as usize]) + .sum(); + for &(_c2, idx2) in v_entries { + let new_msg = graph.prior_llr[v] + total - c_to_v[idx2 as usize]; + v_to_c[idx2 as usize] = + (1.0 - gamma) * new_msg + gamma * v_to_c[idx2 as usize]; + } + } + } + } + + if !serial { + // Flooding: batch update all variables after all checks. + for (v, &prior) in graph.prior_llr.iter().enumerate().take(num_vars) { + let gamma = damp; + let entries = graph.var_entries(v); + let total: f64 = entries.iter().map(|&(_c, idx)| c_to_v[idx as usize]).sum(); + for &(_c, idx) in entries { + let new_msg = prior + total - c_to_v[idx as usize]; + v_to_c[idx as usize] = + (1.0 - gamma) * new_msg + gamma * v_to_c[idx as usize]; + } + } + } + + // EWA: blend current iteration's posterior into the running average. + let w = if iter == 0 && outer == 0 { + 1.0 + } else { + ewa_weight + }; + for (v, ewa) in ewa_posterior.iter_mut().enumerate().take(num_vars) { + let cur_posterior = graph.prior_llr[v] + + graph + .var_entries(v) + .iter() + .map(|&(_c, idx)| c_to_v[idx as usize]) + .sum::(); + *ewa = (1.0 - w) * *ewa + w * cur_posterior; + } + } // end inner iteration loop + } // end outer EWAInit loop + + // Use EWA-averaged posteriors (smoothed across all iterations). + posterior.clear(); + posterior.extend_from_slice(&ewa_posterior); + // Also include final iteration's raw posterior for variables where + // EWA and raw agree in sign (reinforcement). + for (v, post) in posterior.iter_mut().enumerate().take(num_vars) { + let raw = graph.prior_llr[v] + + graph + .var_entries(v) + .iter() + .map(|&(_c, idx)| c_to_v[idx as usize]) + .sum::(); + // If EWA and raw agree, use the one with larger magnitude (more confident). + if (*post > 0.0) == (raw > 0.0) && raw.abs() > post.abs() { + *post = raw; + } + // If they disagree, keep EWA (it's more stable). + } +} + +/// BP on the matching graph (Hack et al. 2026 style). +/// +/// Instead of running BP on the Tanner graph (check matrix), run on the +/// matching graph where: +/// - Variables = matching graph edges (is this edge in the correction?) +/// - Factors = detector nodes (parity constraint from syndrome) +/// +/// The matching graph has simpler topology (no hyperedges, more tree-like), +/// so BP converges better. Returns per-edge posterior LLRs. +#[must_use] +pub fn matching_graph_bp( + graph: &pecos_decoder_core::dem::DemMatchingGraph, + syndrome: &[u8], + num_iterations: usize, + min_sum_scale: f64, +) -> Vec { + let num_nodes = graph.num_detectors + 1; // +1 for boundary + let num_edges = graph.edges.len(); + let boundary = graph.num_detectors; + + // Prior LLRs for each edge. + let prior_llr: Vec = graph.edges.iter().map(|e| e.weight).collect(); + + // Build adjacency: for each node, list of incident edges. + let mut node_edges: Vec> = vec![Vec::new(); num_nodes]; + for (idx, edge) in graph.edges.iter().enumerate() { + node_edges[edge.node1 as usize].push(idx); + if let Some(n2) = edge.node2 { + node_edges[n2 as usize].push(idx); + } else { + node_edges[boundary].push(idx); + } + } + + // Messages: node-to-edge and edge-to-node. + // For each (node, edge) pair, store the message index. + let mut msg_idx = 0usize; + let mut node_msg: Vec> = vec![Vec::new(); num_nodes]; // node -> [(edge_idx, msg_idx)] + let mut edge_msg: Vec> = vec![Vec::new(); num_edges]; // edge -> [(node_idx, msg_idx)] + for (node, edges) in node_edges.iter().enumerate() { + for &edge_idx in edges { + node_msg[node].push((edge_idx, msg_idx)); + edge_msg[edge_idx].push((node, msg_idx)); + msg_idx += 1; + } + } + + let total_msgs = msg_idx; + let mut n_to_e = vec![0.0f64; total_msgs]; // node→edge messages + let mut e_to_n = vec![0.0f64; total_msgs]; // edge→node messages + + // Initialize edge→node with prior LLRs. + for (edge_idx, entries) in edge_msg.iter().enumerate() { + for &(_, midx) in entries { + e_to_n[midx] = prior_llr[edge_idx]; + } + } + + // Syndrome sign. + let syn_sign: Vec = (0..num_nodes) + .map(|n| { + if n < syndrome.len() && syndrome[n] != 0 { + -1.0 + } else { + 1.0 + } + }) + .collect(); + + let damp = 0.25; + + for _iter in 0..num_iterations { + // Node-to-edge (check-to-variable): min-sum update. + // Same as Tanner graph BP but on matching graph nodes. + for node in 0..num_nodes { + let entries = &node_msg[node]; + if entries.len() < 2 { + continue; + } + + let mut total_sign = syn_sign[node]; + let mut min1 = f64::INFINITY; + let mut min2 = f64::INFINITY; + let mut min1_pos = usize::MAX; + + for (pos, &(_, midx)) in entries.iter().enumerate() { + let msg = e_to_n[midx]; + if msg < 0.0 { + total_sign = -total_sign; + } + let abs_msg = msg.abs(); + if abs_msg < min1 { + min2 = min1; + min1 = abs_msg; + min1_pos = pos; + } else if abs_msg < min2 { + min2 = abs_msg; + } + } + + for (pos, &(_, midx)) in entries.iter().enumerate() { + let msg_v = e_to_n[midx]; + let sign_without = total_sign.copysign(total_sign * msg_v); + let min_without = if pos == min1_pos { min2 } else { min1 }; + n_to_e[midx] = sign_without * min_without * min_sum_scale; + } + } + + // Edge-to-node (variable-to-check): sum incoming + prior. + for (edge_idx, entries) in edge_msg.iter().enumerate() { + let total: f64 = entries.iter().map(|&(_, midx)| n_to_e[midx]).sum(); + for &(_, midx) in entries { + let new_msg = prior_llr[edge_idx] + total - n_to_e[midx]; + e_to_n[midx] = (1.0 - damp) * new_msg + damp * e_to_n[midx]; + } + } + } + + // Posterior: prior + sum of all node→edge messages. + let mut posterior = prior_llr; + for (edge_idx, entries) in edge_msg.iter().enumerate() { + for &(_, midx) in entries { + posterior[edge_idx] += n_to_e[midx]; + } + } + + posterior +} + +/// Convenience wrapper: build graph, run BP, return posteriors. +#[must_use] +pub fn min_sum_bp( + dcm: &DemCheckMatrix, + syndrome: &[u8], + num_iterations: usize, + min_sum_scale: f64, +) -> Vec { + let graph = BpGraph::from_dcm(dcm); + let mut c_to_v = vec![0.0f64; graph.total_edges]; + let mut v_to_c = vec![0.0f64; graph.total_edges]; + let mut posterior = Vec::with_capacity(graph.num_vars); + min_sum_bp_into( + &graph, + syndrome, + num_iterations, + min_sum_scale, + false, + &mut c_to_v, + &mut v_to_c, + &mut posterior, + ); + posterior +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_mini_bp_no_syndrome() { + // Simple 2-check, 3-mechanism DEM. + let dem_str = "\ +error(0.1) D0 D1 L0 +error(0.1) D1 +error(0.05) D0 +"; + let dcm = DemCheckMatrix::from_dem_str(dem_str).unwrap(); + let syndrome = vec![0u8; dcm.num_detectors]; + + let posterior = min_sum_bp(&dcm, &syndrome, 5, 0.625); + assert_eq!(posterior.len(), dcm.num_mechanisms); + + // With no syndrome, all posteriors should be positive (no error likely). + for &llr in &posterior { + assert!(llr > 0.0, "Expected positive LLR for no-syndrome case"); + } + } + + #[test] + fn test_mini_bp_with_syndrome() { + let dem_str = "\ +error(0.1) D0 D1 L0 +error(0.1) D1 +error(0.05) D0 +"; + let dcm = DemCheckMatrix::from_dem_str(dem_str).unwrap(); + // D0 and D1 both triggered -> mechanism 0 (D0-D1) is likely. + let syndrome = vec![1, 1]; + + let posterior = min_sum_bp(&dcm, &syndrome, 5, 0.625); + assert_eq!(posterior.len(), dcm.num_mechanisms); + + // Mechanism 0 (D0 D1) should have lower (more negative) LLR + // since both its checks are triggered. + assert!( + posterior[0] < posterior[2], + "Mechanism touching both triggered checks should be more likely" + ); + } +} diff --git a/crates/pecos-uf-decoder/src/windowed.rs b/crates/pecos-uf-decoder/src/windowed.rs new file mode 100644 index 000000000..8864adaf8 --- /dev/null +++ b/crates/pecos-uf-decoder/src/windowed.rs @@ -0,0 +1,1459 @@ +// 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. + +//! Sliding-window decoder for real-time surface code decoding. +//! +//! Two modes: +//! +//! - **Non-overlapping (`buf=0`)**: sub-DEM per window, any inner decoder, +//! observable XOR across windows. Converges to ~1.03x penalty at large r +//! (matching Tan et al.). Inner decoder is pluggable via factory. +//! +//! - **Overlapping (`buf>0`)**: uses `UfDecoder` for edge tracking. Each +//! window is extended by buffer rounds for matching context. Only corrections +//! with both endpoints in the core region are committed. No artificial defect +//! injection — the buffer just provides graph context (Tan et al.). +//! +//! Reference: Tan et al., PRX Quantum 2023 (arXiv:2209.09219). + +use pecos_decoder_core::ObservableDecoder; +use pecos_decoder_core::correlated_decoder::EdgeTrackingDecoder; +use pecos_decoder_core::dem::DemMatchingGraph; +use pecos_decoder_core::errors::DecoderError; +use std::fmt::Write as _; + +/// Configuration for the windowed decoder. +#[derive(Debug, Clone, Copy, Default)] +pub struct WindowedConfig { + /// Commit rounds per window (step size). 0 = auto (code distance). + pub step_size: usize, + /// Buffer rounds on each side of the core. 0 = non-overlapping. + /// Recommended: set equal to code distance for near-zero penalty. + pub buffer_size: usize, + /// Half-width of Type-2 seam windows in rounds. 0 = auto (step/2). + pub seam_half_width: usize, + /// Extend core by this many layers into the buffer on each side. + /// Committed edges can touch the extended core, capturing more + /// boundary corrections. 0 = strict core only (default). + pub core_extend: usize, + /// Maximum edge weight for Phase-1 commit. Only correction edges + /// with weight below this are committed (high-confidence corrections). + /// 0.0 = no threshold (commit all core edges, default). + pub commit_weight_max: f64, +} + +// ============================================================================= +// Non-overlapping windowed decoder (buf=0) +// ============================================================================= + +/// Pre-built window with a generic inner decoder. +struct PrebuiltWindow { + decoder: Box, + local_to_global: Vec, + num_local: usize, +} + +/// Non-overlapping windowed decoder. Any `ObservableDecoder` as inner decoder. +pub struct WindowedDecoder { + windows: Vec, +} + +impl WindowedDecoder { + /// Create from a DEM string with a decoder factory. + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed or the factory fails. + pub fn from_dem( + dem: &str, + config: WindowedConfig, + mut decoder_factory: F, + ) -> Result + where + F: FnMut(&str) -> Result, DecoderError>, + { + let (det_times, num_detectors, step_size, total_t) = parse_dem_params(dem, &config)?; + let mut windows = Vec::new(); + let mut t_start = 0.0f64; + + while t_start < total_t { + let is_last = t_start + 2.0 * step_size as f64 > total_t; + let t_end = if is_last { + total_t + 1.0 + } else { + t_start + step_size as f64 + }; + + let (local_to_global, window_dem) = + extract_window_dem(dem, &det_times, num_detectors, t_start, t_end); + + let num_local = local_to_global.len(); + if num_local > 0 && !window_dem.is_empty() { + let decoder = decoder_factory(&window_dem)?; + windows.push(PrebuiltWindow { + decoder, + local_to_global, + num_local, + }); + } + + t_start += step_size as f64; + } + + Ok(Self { windows }) + } + + /// Number of windows. + #[must_use] + pub fn num_windows(&self) -> usize { + self.windows.len() + } +} + +impl ObservableDecoder for WindowedDecoder { + fn decode_to_observables(&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]; + for (local_id, &global_id) in window.local_to_global.iter().enumerate() { + let gid = global_id as usize; + if gid < syndrome.len() { + window_syn[local_id] = syndrome[gid]; + } + } + obs_mask ^= window.decoder.decode_to_observables(&window_syn)?; + } + Ok(obs_mask) + } +} + +// ============================================================================= +// Overlapping windowed decoder (buf>0, Tan et al.) +// ============================================================================= + +/// Pre-built overlapping window with an edge-tracking inner decoder. +struct OverlappingWindow { + decoder: D, + local_to_global: Vec, + /// Per local detector: true = core region, false = buffer. + is_core: Vec, + num_local: usize, +} + +/// Overlapping windowed decoder using any `EdgeTrackingDecoder` for edge tracking. +/// +/// Each window is extended by buffer rounds for matching context. +/// Only core corrections are committed; buffer corrections are discarded. +pub struct OverlappingWindowedDecoder { + windows: Vec>, +} + +impl OverlappingWindowedDecoder { + /// Create from a DEM string with a factory for the inner decoder. + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed or the factory fails. + pub fn from_dem( + dem: &str, + config: WindowedConfig, + mut factory: F, + ) -> Result + where + F: FnMut(&str) -> Result, + { + let (det_times, num_detectors, step_size, total_t) = parse_dem_params(dem, &config)?; + let buffer_size = config.buffer_size; + let mut windows = Vec::new(); + let mut t_start = 0.0f64; + + while t_start < total_t { + let is_last = t_start + 2.0 * step_size as f64 > total_t; + let t_core_end = if is_last { + total_t + 1.0 + } else { + t_start + step_size as f64 + }; + let t_win_start = (t_start - buffer_size as f64).max(0.0); + let t_win_end = if is_last { + total_t + 1.0 + } else { + t_core_end + buffer_size as f64 + }; + + let (local_to_global, window_dem) = + extract_window_dem(dem, &det_times, num_detectors, t_win_start, t_win_end); + + let ext = config.core_extend as f64; + let is_core: Vec = local_to_global + .iter() + .map(|&gid| { + let t = det_times[gid as usize]; + t >= (t_start - ext) && t < (t_core_end + ext) + }) + .collect(); + + let num_local = local_to_global.len(); + if num_local > 0 && !window_dem.is_empty() { + let decoder = factory(&window_dem)?; + windows.push(OverlappingWindow { + decoder, + local_to_global, + is_core, + num_local, + }); + } + + t_start += step_size as f64; + } + + Ok(Self { windows }) + } + + /// Number of windows. + #[must_use] + pub fn num_windows(&self) -> usize { + self.windows.len() + } +} + +impl ObservableDecoder for OverlappingWindowedDecoder { + fn decode_to_observables(&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]; + for (local_id, &global_id) in window.local_to_global.iter().enumerate() { + let gid = global_id as usize; + if gid < syndrome.len() { + window_syn[local_id] = syndrome[gid]; + } + } + + // Use MatchingDecoder trait for edge tracking. + let (_, matched_edges) = window.decoder.decode_with_matching(&window_syn)?; + + let boundary = window.num_local as u32; + for &edge_idx in &matched_edges { + let n1 = window.decoder.edge_node1(edge_idx); + let n2 = window.decoder.edge_node2(edge_idx); + + let n1_core = n1 >= boundary + || ((n1 as usize) < window.is_core.len() && window.is_core[n1 as usize]); + let n2_core = n2 >= boundary + || ((n2 as usize) < window.is_core.len() && window.is_core[n2 as usize]); + + if n1_core && n2_core { + obs_mask ^= window.decoder.edge_obs_mask(edge_idx); + } + } + } + + Ok(obs_mask) + } +} + +// ============================================================================= +// Sandwich windowed decoder (Tan et al. two-phase) +// ============================================================================= + +/// Sandwich windowed decoder: two-phase decoding for reduced boundary penalty. +/// +/// Phase 1 (Type-1): Overlapping windows with core-only commit, same as +/// `OverlappingWindowedDecoder`. Independent, can run in parallel. +/// +/// Phase 2 (Type-2): Small seam windows at core boundaries decode the +/// residual syndrome left by Type-1. The residual is computed as +/// `original XOR correction_effect` where `correction_effect` tracks +/// which syndrome bits were flipped by Type-1's committed edges. +/// +/// This gives Type-2 bidirectional boundary information from both +/// flanking Type-1 windows, reducing the boundary penalty. +pub struct SandwichWindowedDecoder { + type1_windows: Vec>, + residual_decoder: Box, + num_detectors: usize, + commit_weight_max: f64, +} + +impl SandwichWindowedDecoder { + /// Create from a DEM string with factories for Phase-1 and Phase-2 decoders. + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed or factories fail. + pub fn from_dem( + dem: &str, + config: WindowedConfig, + mut phase1_factory: F1, + mut phase2_factory: F2, + ) -> Result + where + F1: FnMut(&str) -> Result, + F2: FnMut(&str) -> Result, DecoderError>, + { + let (det_times, num_detectors, step_size, total_t) = parse_dem_params(dem, &config)?; + let buffer_size = config.buffer_size; + + let mut type1_windows = Vec::new(); + let mut t_start = 0.0f64; + + while t_start < total_t { + let is_last = t_start + 2.0 * step_size as f64 > total_t; + let t_core_end = if is_last { + total_t + 1.0 + } else { + t_start + step_size as f64 + }; + let t_win_start = (t_start - buffer_size as f64).max(0.0); + let t_win_end = if is_last { + total_t + 1.0 + } else { + t_core_end + buffer_size as f64 + }; + + let (local_to_global, window_dem) = + extract_window_dem(dem, &det_times, num_detectors, t_win_start, t_win_end); + + let ext = config.core_extend as f64; + let is_core: Vec = local_to_global + .iter() + .map(|&gid| { + let t = det_times[gid as usize]; + t >= (t_start - ext) && t < (t_core_end + ext) + }) + .collect(); + + let num_local = local_to_global.len(); + if num_local > 0 && !window_dem.is_empty() { + let decoder = phase1_factory(&window_dem)?; + type1_windows.push(OverlappingWindow { + decoder, + local_to_global, + is_core, + num_local, + }); + } + + t_start += step_size as f64; + } + + let residual_decoder = phase2_factory(dem)?; + + Ok(Self { + type1_windows, + residual_decoder, + num_detectors, + commit_weight_max: config.commit_weight_max, + }) + } + + /// Number of Type-1 windows. + #[must_use] + pub fn num_windows(&self) -> usize { + self.type1_windows.len() + } + + /// Decode with parallel Phase-1 windows using rayon. + /// + /// Requires `D: Send` for thread safety. Phase-1 windows run on rayon's + /// thread pool; Phase-2 residual runs sequentially after. + /// + /// # Errors + /// + /// Returns `DecoderError` if any window decoder fails. + pub fn decode_parallel(&mut self, syndrome: &[u8]) -> Result + where + D: Send, + { + use rayon::prelude::*; + + let commit_weight_max = self.commit_weight_max; + let num_detectors = self.num_detectors; + + // Phase 1: Decode Type-1 windows in parallel. + let window_results: Result, DecoderError> = self + .type1_windows + .par_iter_mut() + .map(|window| { + let mut window_syn = vec![0u8; window.num_local]; + for (local_id, &global_id) in window.local_to_global.iter().enumerate() { + let gid = global_id as usize; + if gid < syndrome.len() { + window_syn[local_id] = syndrome[gid]; + } + } + + let (_, matched_edges) = window.decoder.decode_with_matching(&window_syn)?; + + let mut obs = 0u64; + let mut corrections: Vec<(usize, u8)> = Vec::new(); + let boundary = window.num_local as u32; + + for &edge_idx in &matched_edges { + let n1 = window.decoder.edge_node1(edge_idx); + let n2 = window.decoder.edge_node2(edge_idx); + + let n1_core = n1 >= boundary + || ((n1 as usize) < window.is_core.len() && window.is_core[n1 as usize]); + let n2_core = n2 >= boundary + || ((n2 as usize) < window.is_core.len() && window.is_core[n2 as usize]); + + let weight_ok = commit_weight_max <= 0.0 + || window.decoder.edge_weight(edge_idx) <= commit_weight_max; + + if n1_core && n2_core && weight_ok { + obs ^= window.decoder.edge_obs_mask(edge_idx); + if (n1 as usize) < window.num_local { + corrections.push((window.local_to_global[n1 as usize] as usize, 1)); + } + if (n2 as usize) < window.num_local { + corrections.push((window.local_to_global[n2 as usize] as usize, 1)); + } + } + } + + Ok((obs, corrections)) + }) + .collect(); + + // Merge results (XOR is order-independent). + let mut obs_mask = 0u64; + let mut correction_effect = vec![0u8; num_detectors]; + for (window_obs, corrections) in window_results? { + obs_mask ^= window_obs; + for (gid, bit) in corrections { + correction_effect[gid] ^= bit; + } + } + + // Phase 2: Residual decode (sequential). + let mut residual_syn = vec![0u8; num_detectors]; + for (i, &s) in syndrome.iter().enumerate() { + if i < num_detectors { + residual_syn[i] = s ^ correction_effect[i]; + } + } + obs_mask ^= self.residual_decoder.decode_to_observables(&residual_syn)?; + + Ok(obs_mask) + } +} + +impl ObservableDecoder for SandwichWindowedDecoder { + fn decode_to_observables(&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; + + // Phase 1: Decode Type-1 windows. + for window in &mut self.type1_windows { + let mut window_syn = vec![0u8; window.num_local]; + for (local_id, &global_id) in window.local_to_global.iter().enumerate() { + let gid = global_id as usize; + if gid < syndrome.len() { + window_syn[local_id] = syndrome[gid]; + } + } + + let (_, matched_edges) = window.decoder.decode_with_matching(&window_syn)?; + + let boundary = window.num_local as u32; + for &edge_idx in &matched_edges { + let n1 = window.decoder.edge_node1(edge_idx); + let n2 = window.decoder.edge_node2(edge_idx); + + let n1_core = n1 >= boundary + || ((n1 as usize) < window.is_core.len() && window.is_core[n1 as usize]); + let n2_core = n2 >= boundary + || ((n2 as usize) < window.is_core.len() && window.is_core[n2 as usize]); + + let weight_ok = commit_weight_max <= 0.0 + || window.decoder.edge_weight(edge_idx) <= commit_weight_max; + + if n1_core && n2_core && weight_ok { + obs_mask ^= window.decoder.edge_obs_mask(edge_idx); + + if (n1 as usize) < window.num_local { + let gid = window.local_to_global[n1 as usize] as usize; + correction_effect[gid] ^= 1; + } + if (n2 as usize) < window.num_local { + let gid = window.local_to_global[n2 as usize] as usize; + correction_effect[gid] ^= 1; + } + } + } + } + + // Phase 2: Decode residual syndrome on the full graph. + let mut residual_syn = vec![0u8; self.num_detectors]; + for (i, &s) in syndrome.iter().enumerate() { + if i < self.num_detectors { + residual_syn[i] = s ^ correction_effect[i]; + } + } + obs_mask ^= self.residual_decoder.decode_to_observables(&residual_syn)?; + + Ok(obs_mask) + } +} + +// ============================================================================= +// Shared helpers +// ============================================================================= + +/// Parse DEM parameters for windowing. +fn parse_dem_params( + dem: &str, + config: &WindowedConfig, +) -> Result<(Vec, usize, usize, f64), DecoderError> { + let graph = DemMatchingGraph::from_dem_str(dem)?; + let num_detectors = graph.num_detectors; + + let mut det_times = vec![0.0f64; num_detectors]; + let mut max_time = 0.0f64; + for (i, coord) in graph.detector_coords.iter().enumerate() { + if let Some(c) = coord { + let t = c.get(2).copied().unwrap_or(0.0); + if i < det_times.len() { + det_times[i] = t; + } + if t > max_time { + max_time = t; + } + } + } + + let num_rounds = (max_time + 1.0) as usize; + let num_stab = num_detectors + .checked_div(num_rounds) + .unwrap_or(num_detectors); + let d_est = ((num_stab as f64).sqrt().ceil() as usize).max(3); + let step_size = if config.step_size > 0 { + config.step_size + } else { + d_est + }; + let total_t = num_rounds as f64; + + Ok((det_times, num_detectors, step_size, total_t)) +} + +/// Extract a window sub-DEM by filtering the original DEM text. +/// +/// Detectors in `[t_start, t_end)` are included and remapped to local IDs. +/// Detectors outside the window are dropped from error mechanisms, creating +/// implicit boundary edges. +fn extract_window_dem( + dem: &str, + det_times: &[f64], + num_det: usize, + t_start: f64, + t_end: f64, +) -> (Vec, String) { + let mut in_window = vec![false; num_det]; + let mut local_to_global: Vec = Vec::new(); + let mut global_to_local: Vec> = vec![None; num_det]; + + for (i, &t) in det_times.iter().enumerate() { + if t >= t_start && t < t_end { + in_window[i] = true; + global_to_local[i] = Some(local_to_global.len() as u32); + local_to_global.push(i as u32); + } + } + + let mut out = String::new(); + + for line in dem.lines() { + let trimmed = line.trim(); + if trimmed.is_empty() || trimmed.starts_with('#') { + continue; + } + + if trimmed.starts_with("error(") { + let Some(close) = trimmed.find(')') else { + continue; + }; + let prob_str = &trimmed[6..close]; + let rest = &trimmed[close + 1..]; + let tokens: Vec<&str> = rest.split_whitespace().collect(); + + // Split by ^ into decomposed segments. + let mut segments: Vec> = vec![Vec::new()]; + for tok in &tokens { + if *tok == "^" { + segments.push(Vec::new()); + } else { + segments.last_mut().unwrap().push(tok); + } + } + + let mut remapped_segments: Vec = Vec::new(); + for seg in &segments { + let mut seg_dets: Vec = Vec::new(); + let mut seg_obs: Vec = Vec::new(); + let mut seg_any_in = false; + + for tok in seg { + if let Some(d_str) = tok.strip_prefix('D') { + if let Ok(d) = d_str.parse::() + && d < num_det + && in_window[d] + { + seg_any_in = true; + if let Some(local) = global_to_local[d] { + seg_dets.push(format!("D{local}")); + } + } + } else if tok.starts_with('L') { + seg_obs.push((*tok).to_string()); + } + } + + if seg_any_in { + let mut seg_str = seg_dets.join(" "); + for obs in &seg_obs { + seg_str.push(' '); + seg_str.push_str(obs); + } + remapped_segments.push(seg_str); + } + } + + if !remapped_segments.is_empty() { + let _ = write!(out, "error({prob_str}) "); + out.push_str(&remapped_segments.join(" ^ ")); + out.push('\n'); + } + } else if trimmed.starts_with("detector(") + && let Some(d_start) = trimmed.rfind('D') + && let Ok(d) = trimmed[d_start + 1..].trim().parse::() + && d < num_det + && in_window[d] + && let Some(local) = global_to_local[d] + { + let coords_end = trimmed.find(')').unwrap_or(trimmed.len()); + out.push_str(&trimmed[..=coords_end]); + let _ = writeln!(out, " D{local}"); + } + } + + (local_to_global, out) +} + +// ============================================================================= +// Streaming windowed decoder +// ============================================================================= + +use std::collections::BTreeMap; + +/// Streaming windowed decoder that accepts syndrome data round-by-round. +/// +/// Precomputes round-to-detector mapping from DEM coordinates. As rounds +/// arrive via `feed_round`, buffers syndrome data and triggers window +/// decoding when each window's extended region is complete. Emits partial +/// observable corrections as windows commit. +pub struct StreamingWindowedDecoder { + /// Prebuilt windows, ordered by start time. + windows: Vec>, + /// Round number → list of (`local_window_idx`, `local_detector_idx`) for each window. + round_to_dets: BTreeMap>, + /// Per-window syndrome buffers. + window_syndromes: Vec>, + /// Round at which each window becomes decodable (all data received). + window_ready_round: Vec, + /// Index of next window to decode. + next_decode: usize, + /// Accumulated observable corrections. + accumulated: u64, +} + +impl StreamingWindowedDecoder { + /// Create from a DEM string with factory. + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed or factory fails. + pub fn from_dem( + dem: &str, + config: WindowedConfig, + mut factory: F, + ) -> Result + where + F: FnMut(&str) -> Result, + { + let (det_times, num_detectors, step_size, total_t) = parse_dem_params(dem, &config)?; + let buffer_size = config.buffer_size; + + // Build windows (same as OverlappingWindowedDecoder). + let mut windows = Vec::new(); + let mut window_ranges: Vec<(f64, f64, f64)> = Vec::new(); // (win_start, win_end, core_end) + let mut t_start = 0.0f64; + + while t_start < total_t { + let is_last = t_start + 2.0 * step_size as f64 > total_t; + let t_core_end = if is_last { + total_t + 1.0 + } else { + t_start + step_size as f64 + }; + let t_win_start = (t_start - buffer_size as f64).max(0.0); + let t_win_end = if is_last { + total_t + 1.0 + } else { + t_core_end + buffer_size as f64 + }; + + let (local_to_global, window_dem) = + extract_window_dem(dem, &det_times, num_detectors, t_win_start, t_win_end); + + let ext = config.core_extend as f64; + let is_core: Vec = local_to_global + .iter() + .map(|&gid| { + let t = det_times[gid as usize]; + t >= (t_start - ext) && t < (t_core_end + ext) + }) + .collect(); + + let num_local = local_to_global.len(); + if num_local > 0 && !window_dem.is_empty() { + let decoder = factory(&window_dem)?; + window_ranges.push((t_win_start, t_win_end, t_core_end)); + windows.push(OverlappingWindow { + decoder, + local_to_global, + is_core, + num_local, + }); + } + + t_start += step_size as f64; + } + + // Build round → (window_idx, local_det) mapping. + let mut round_to_dets: BTreeMap> = BTreeMap::new(); + for (win_idx, window) in windows.iter().enumerate() { + for (local_id, &global_id) in window.local_to_global.iter().enumerate() { + let round = det_times[global_id as usize] as usize; + round_to_dets + .entry(round) + .or_default() + .push((win_idx, local_id)); + } + } + + // Compute when each window has all its data. + let window_ready_round: Vec = window_ranges + .iter() + .map(|&(_, t_end, _)| (t_end.ceil() as usize).saturating_sub(1)) + .collect(); + + let window_syndromes = windows.iter().map(|w| vec![0u8; w.num_local]).collect(); + + Ok(Self { + windows, + round_to_dets, + window_syndromes, + window_ready_round, + next_decode: 0, + accumulated: 0, + }) + } + + /// Decode a ready window and return its observable contribution. + fn decode_window(&mut self, win_idx: usize) -> Result { + let window = &mut self.windows[win_idx]; + let syn = &self.window_syndromes[win_idx]; + + let (_, matched_edges) = window.decoder.decode_with_matching(syn)?; + + let mut obs = 0u64; + let boundary = window.num_local as u32; + for &edge_idx in &matched_edges { + let n1 = window.decoder.edge_node1(edge_idx); + let n2 = window.decoder.edge_node2(edge_idx); + + let n1_core = n1 >= boundary + || ((n1 as usize) < window.is_core.len() && window.is_core[n1 as usize]); + let n2_core = n2 >= boundary + || ((n2 as usize) < window.is_core.len() && window.is_core[n2 as usize]); + + if n1_core && n2_core { + obs ^= window.decoder.edge_obs_mask(edge_idx); + } + } + Ok(obs) + } +} + +impl pecos_decoder_core::streaming::StreamingDecoder + for StreamingWindowedDecoder +{ + fn feed_round(&mut self, round: usize, detectors: &[(u32, u8)]) -> Result { + // Store detection events into each window's syndrome buffer. + for &(det, val) in detectors { + if let Some(entries) = self.round_to_dets.get(&round) { + for &(win_idx, local_id) in entries { + // Check if this detector matches + if self.windows[win_idx].local_to_global.get(local_id) == Some(&det) { + self.window_syndromes[win_idx][local_id] = val; + } + } + } + } + + // Also store by global detector index for windows that contain this detector. + for &(det, val) in detectors { + for (win_idx, window) in self.windows.iter().enumerate() { + for (local_id, &global_id) in window.local_to_global.iter().enumerate() { + if global_id == det { + self.window_syndromes[win_idx][local_id] = val; + } + } + } + } + + // Check if any window became ready. + let mut new_obs = 0u64; + while self.next_decode < self.windows.len() { + if round < self.window_ready_round[self.next_decode] { + break; + } + new_obs ^= self.decode_window(self.next_decode)?; + self.next_decode += 1; + } + + self.accumulated ^= new_obs; + Ok(new_obs) + } + + fn flush(&mut self) -> Result { + let mut new_obs = 0u64; + while self.next_decode < self.windows.len() { + new_obs ^= self.decode_window(self.next_decode)?; + self.next_decode += 1; + } + self.accumulated ^= new_obs; + Ok(new_obs) + } + + fn accumulated_obs(&self) -> u64 { + self.accumulated + } + + fn reset(&mut self) { + for syn in &mut self.window_syndromes { + syn.fill(0); + } + self.next_decode = 0; + self.accumulated = 0; + } +} + +// ============================================================================= +// Beam search windowed decoder +// ============================================================================= + +/// Configuration for the beam search windowed decoder. +#[derive(Debug, Clone, Copy)] +pub struct BeamSearchConfig { + /// Windowed decoder parameters. + pub window: WindowedConfig, + /// Number of beam hypotheses (K). Default 5. + pub beam_width: usize, + /// Perturbation sigma for log-normal weight noise. Default 0.5. + pub perturbation_sigma: f64, + /// RNG seed for reproducibility. + pub seed: u64, +} + +impl Default for BeamSearchConfig { + fn default() -> Self { + Self { + window: WindowedConfig::default(), + beam_width: 5, + perturbation_sigma: 0.5, + seed: 42, + } + } +} + +/// One beam hypothesis: accumulated state from windows processed so far. +struct Hypothesis { + correction_effect: Vec, + obs_mask: u64, + total_weight: f64, +} + +/// Per-window storage: K decoders (1 unperturbed + K-1 perturbed). +struct BeamWindow { + decoders: Vec, + local_to_global: Vec, + is_core: Vec, + num_local: usize, +} + +/// Beam search windowed decoder. +/// +/// Maintains K correction hypotheses across window boundaries. Each window +/// expands K hypotheses × K perturbed decoders = K² candidates, pruned +/// to K by total correction weight. After all windows, picks the +/// lowest-weight hypothesis and optionally runs a Phase-2 residual decode. +/// +/// The key insight: different hypotheses propagate different +/// `correction_effect` vectors to subsequent windows, so each hypothesis +/// sees a different modified syndrome. This explores different string +/// continuations across window boundaries. +pub struct BeamSearchWindowedDecoder { + windows: Vec>, + num_detectors: usize, + beam_width: usize, + commit_weight_max: f64, + residual_decoder: Option>, +} + +impl BeamSearchWindowedDecoder { + /// Create from a DEM string. + /// + /// `phase1_factory` builds the inner edge-tracking decoder from a sub-DEM. + /// `phase2_factory` (optional) builds the full-graph residual decoder. + /// + /// # Errors + /// + /// Returns `DecoderError` if the DEM is malformed or factories fail. + pub fn from_dem( + dem: &str, + config: BeamSearchConfig, + mut phase1_factory: F1, + mut phase2_factory: Option, + ) -> Result + where + F1: FnMut(&str) -> Result, + F2: FnMut(&str) -> Result, DecoderError>, + { + let (det_times, num_detectors, step_size, total_t) = parse_dem_params(dem, &config.window)?; + let buffer_size = config.window.buffer_size; + let k = config.beam_width; + + let mut windows = Vec::new(); + let mut t_start = 0.0f64; + + while t_start < total_t { + let is_last = t_start + 2.0 * step_size as f64 > total_t; + let t_core_end = if is_last { + total_t + 1.0 + } else { + t_start + step_size as f64 + }; + let t_win_start = (t_start - buffer_size as f64).max(0.0); + let t_win_end = if is_last { + total_t + 1.0 + } else { + t_core_end + buffer_size as f64 + }; + + let (local_to_global, window_dem) = + extract_window_dem(dem, &det_times, num_detectors, t_win_start, t_win_end); + + let ext = config.window.core_extend as f64; + let is_core: Vec = local_to_global + .iter() + .map(|&gid| { + let t = det_times[gid as usize]; + t >= (t_start - ext) && t < (t_core_end + ext) + }) + .collect(); + + let num_local = local_to_global.len(); + if num_local > 0 && !window_dem.is_empty() { + let mut decoders = Vec::with_capacity(k); + + // Decoder 0: unperturbed anchor + decoders.push(phase1_factory(&window_dem)?); + + // Decoders 1..K-1: perturbed weights + for member_idx in 1..k { + let mut rng = pecos_random::PecosRng::seed_from_u64( + config.seed.wrapping_add(member_idx as u64), + ); + let mut next_f64 = || rng.next_f64(); + let perturbed = pecos_decoder_core::perturbed::perturb_dem( + &window_dem, + config.perturbation_sigma, + &mut next_f64, + ); + if let Ok(dec) = phase1_factory(&perturbed) { + decoders.push(dec); + } + } + + windows.push(BeamWindow { + decoders, + local_to_global, + is_core, + num_local, + }); + } + + t_start += step_size as f64; + } + + let residual_decoder = if let Some(ref mut f2) = phase2_factory { + Some(f2(dem)?) + } else { + None + }; + + Ok(Self { + windows, + num_detectors, + beam_width: k, + commit_weight_max: config.window.commit_weight_max, + residual_decoder, + }) + } + + /// Number of windows. + #[must_use] + pub fn num_windows(&self) -> usize { + self.windows.len() + } +} + +impl ObservableDecoder for BeamSearchWindowedDecoder { + fn decode_to_observables(&mut self, syndrome: &[u8]) -> Result { + let k = self.beam_width; + let commit_weight_max = self.commit_weight_max; + + // Initialize beam with K identical empty hypotheses. + let mut beam: Vec = (0..k) + .map(|_| Hypothesis { + correction_effect: vec![0u8; self.num_detectors], + obs_mask: 0, + total_weight: 0.0, + }) + .collect(); + + // Process each window: expand K hypotheses × K decoders → prune to K. + for window in &mut self.windows { + let actual_k = window.decoders.len(); + let mut candidates: Vec = Vec::with_capacity(beam.len() * actual_k); + + // Build window syndrome from the original (Phase-1 windows are + // independent — correction_effect is only used for Phase-2 residual). + let mut window_syn = vec![0u8; window.num_local]; + for (local_id, &global_id) in window.local_to_global.iter().enumerate() { + let gid = global_id as usize; + if gid < syndrome.len() { + window_syn[local_id] = syndrome[gid]; + } + } + + for hyp in &beam { + // Decode with each perturbed decoder. + for decoder in &mut window.decoders { + let (_, matched_edges) = decoder.decode_with_matching(&window_syn)?; + + let mut new_obs = hyp.obs_mask; + let mut new_correction = hyp.correction_effect.clone(); + let mut new_weight = hyp.total_weight; + let boundary = window.num_local as u32; + + for &edge_idx in &matched_edges { + let n1 = decoder.edge_node1(edge_idx); + let n2 = decoder.edge_node2(edge_idx); + + let n1_core = n1 >= boundary + || ((n1 as usize) < window.is_core.len() + && window.is_core[n1 as usize]); + let n2_core = n2 >= boundary + || ((n2 as usize) < window.is_core.len() + && window.is_core[n2 as usize]); + + let weight_ok = commit_weight_max <= 0.0 + || decoder.edge_weight(edge_idx) <= commit_weight_max; + + if n1_core && n2_core && weight_ok { + new_obs ^= decoder.edge_obs_mask(edge_idx); + new_weight += decoder.edge_weight(edge_idx); + + if (n1 as usize) < window.num_local { + let gid = window.local_to_global[n1 as usize] as usize; + new_correction[gid] ^= 1; + } + if (n2 as usize) < window.num_local { + let gid = window.local_to_global[n2 as usize] as usize; + new_correction[gid] ^= 1; + } + } + } + + candidates.push(Hypothesis { + correction_effect: new_correction, + obs_mask: new_obs, + total_weight: new_weight, + }); + } + } + + // Prune: sort by total weight (lower = more likely), dedup, truncate. + candidates.sort_by(|a, b| { + a.total_weight + .partial_cmp(&b.total_weight) + .unwrap_or(std::cmp::Ordering::Equal) + }); + candidates.dedup_by(|a, b| a.correction_effect == b.correction_effect); + candidates.truncate(k); + beam = candidates; + } + + // Pick the result via majority vote across surviving hypotheses. + // 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); + } + + // Collect final observable predictions from each hypothesis. + let mut predictions: Vec = Vec::with_capacity(beam.len()); + if let Some(ref mut residual_dec) = self.residual_decoder { + for hyp in &beam { + let mut residual_syn = vec![0u8; self.num_detectors]; + for (i, &s) in syndrome.iter().enumerate() { + if i < self.num_detectors { + residual_syn[i] = s ^ hyp.correction_effect[i]; + } + } + let phase2_obs = residual_dec.decode_to_observables(&residual_syn)?; + predictions.push(hyp.obs_mask ^ phase2_obs); + } + } else { + for hyp in &beam { + predictions.push(hyp.obs_mask); + } + } + + // Majority vote across hypotheses (per observable bit). + let half = predictions.len() / 2; + let mut result = 0u64; + for bit in 0..64u32 { + let mask = 1u64 << bit; + let count = predictions.iter().filter(|&&p| p & mask != 0).count(); + if count > half { + result |= mask; + } + } + Ok(result) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + const D3_DEM: &str = + include_str!("../../../examples/surface_code_circuits/surface_code_d3_z_stim.dem"); + + fn uf_factory(dem: &str) -> Result, DecoderError> { + Ok(Box::new(crate::UfDecoder::from_dem( + dem, + crate::UfDecoderConfig::fast(), + )?)) + } + + fn uf_edge_factory(dem: &str) -> Result { + crate::UfDecoder::from_dem(dem, crate::UfDecoderConfig::windowed()) + } + + #[test] + fn test_windowed_construction() { + let dec = WindowedDecoder::from_dem(D3_DEM, WindowedConfig::default(), uf_factory); + assert!(dec.is_ok()); + assert!(dec.unwrap().num_windows() > 0); + } + + #[test] + fn test_windowed_no_errors() { + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let mut dec = + WindowedDecoder::from_dem(D3_DEM, WindowedConfig::default(), uf_factory).unwrap(); + let obs = dec.decode_to_observables(&vec![0u8; graph.num_detectors]); + assert!(obs.is_ok()); + assert_eq!(obs.unwrap(), 0); + } + + #[test] + fn test_single_window_matches_full() { + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let config = WindowedConfig { + step_size: 100, + buffer_size: 0, + ..Default::default() + }; + let mut wdec = WindowedDecoder::from_dem(D3_DEM, config, uf_factory).unwrap(); + let mut udec = crate::UfDecoder::from_dem(D3_DEM, crate::UfDecoderConfig::fast()).unwrap(); + + let syn = vec![0u8; graph.num_detectors]; + assert_eq!( + wdec.decode_to_observables(&syn).unwrap(), + udec.decode_to_observables(&syn).unwrap(), + ); + } + + #[test] + fn test_overlapping_construction() { + let config = WindowedConfig { + step_size: 3, + buffer_size: 2, + ..Default::default() + }; + let dec = OverlappingWindowedDecoder::from_dem(D3_DEM, config, uf_edge_factory); + assert!(dec.is_ok()); + assert!(dec.unwrap().num_windows() > 0); + } + + #[test] + fn test_overlapping_no_errors() { + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let config = WindowedConfig { + step_size: 3, + buffer_size: 2, + ..Default::default() + }; + let mut dec = + OverlappingWindowedDecoder::from_dem(D3_DEM, config, uf_edge_factory).unwrap(); + let obs = dec.decode_to_observables(&vec![0u8; graph.num_detectors]); + assert!(obs.is_ok()); + assert_eq!(obs.unwrap(), 0); + } + + #[test] + fn test_overlapping_single_window() { + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let config = WindowedConfig { + step_size: 100, + buffer_size: 5, + ..Default::default() + }; + let mut dec = + OverlappingWindowedDecoder::from_dem(D3_DEM, config, uf_edge_factory).unwrap(); + assert_eq!(dec.num_windows(), 1); + let syn = vec![0u8; graph.num_detectors]; + assert_eq!(dec.decode_to_observables(&syn).unwrap(), 0); + } + + #[test] + fn test_sandwich_construction() { + let config = WindowedConfig { + step_size: 3, + buffer_size: 3, + ..Default::default() + }; + let dec = SandwichWindowedDecoder::from_dem(D3_DEM, config, uf_edge_factory, uf_factory); + assert!(dec.is_ok()); + let dec = dec.unwrap(); + assert!(dec.num_windows() > 0); + } + + #[test] + fn test_sandwich_no_errors() { + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let config = WindowedConfig { + step_size: 3, + buffer_size: 3, + ..Default::default() + }; + let mut dec = + SandwichWindowedDecoder::from_dem(D3_DEM, config, uf_edge_factory, uf_factory).unwrap(); + let obs = dec.decode_to_observables(&vec![0u8; graph.num_detectors]); + assert!(obs.is_ok()); + assert_eq!(obs.unwrap(), 0); + } + + #[test] + fn test_sandwich_parallel_matches_sequential() { + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let config = WindowedConfig { + step_size: 3, + buffer_size: 3, + ..Default::default() + }; + let mut dec = + SandwichWindowedDecoder::from_dem(D3_DEM, config, uf_edge_factory, uf_factory).unwrap(); + + let syn = vec![0u8; graph.num_detectors]; + let seq = dec.decode_to_observables(&syn).unwrap(); + let par = dec.decode_parallel(&syn).unwrap(); + assert_eq!(seq, par); + } + + #[test] + fn test_streaming_construction() { + let config = WindowedConfig { + step_size: 3, + buffer_size: 2, + ..Default::default() + }; + let dec = StreamingWindowedDecoder::from_dem(D3_DEM, config, uf_edge_factory); + assert!(dec.is_ok()); + } + + #[test] + fn test_streaming_no_errors() { + use pecos_decoder_core::streaming::StreamingDecoder; + + let config = WindowedConfig { + step_size: 3, + buffer_size: 2, + ..Default::default() + }; + let mut dec = StreamingWindowedDecoder::from_dem(D3_DEM, config, uf_edge_factory).unwrap(); + + // Feed empty rounds — no detectors fire. + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let max_round = graph + .detector_coords + .iter() + .filter_map(|c| c.as_ref().and_then(|v| v.get(2)).copied()) + .fold(0.0f64, f64::max) as usize; + + for r in 0..=max_round { + dec.feed_round(r, &[]).unwrap(); + } + dec.flush().unwrap(); + assert_eq!(dec.accumulated_obs(), 0); + } + + #[test] + fn test_beam_k1_matches_sandwich_nonzero() { + // K=1 beam with non-zero syndrome should match sandwich. + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let wconfig = WindowedConfig { + step_size: 3, + buffer_size: 3, + commit_weight_max: 2.5, + ..Default::default() + }; + + let mut sandwich = + SandwichWindowedDecoder::from_dem(D3_DEM, wconfig, uf_edge_factory, uf_factory) + .unwrap(); + + let bconfig = BeamSearchConfig { + window: wconfig, + beam_width: 1, + perturbation_sigma: 0.0, + seed: 42, + }; + let mut beam = + BeamSearchWindowedDecoder::from_dem(D3_DEM, bconfig, uf_edge_factory, Some(uf_factory)) + .unwrap(); + + // Test with single-defect syndrome. + let mut syn = vec![0u8; graph.num_detectors]; + syn[0] = 1; + let sw_obs = sandwich.decode_to_observables(&syn).unwrap(); + let bm_obs = beam.decode_to_observables(&syn).unwrap(); + assert_eq!( + sw_obs, bm_obs, + "K=1 beam should match sandwich. sw={sw_obs}, bm={bm_obs}" + ); + + // Test with two defects. + syn[0] = 1; + syn[1] = 1; + let sw_obs = sandwich.decode_to_observables(&syn).unwrap(); + let bm_obs = beam.decode_to_observables(&syn).unwrap(); + assert_eq!( + sw_obs, bm_obs, + "K=1 beam should match sandwich on 2 defects. sw={sw_obs}, bm={bm_obs}" + ); + } + + #[test] + fn test_beam_search_construction() { + let config = BeamSearchConfig { + window: WindowedConfig { + step_size: 3, + buffer_size: 3, + ..Default::default() + }, + beam_width: 3, + perturbation_sigma: 0.5, + seed: 42, + }; + let dec = + BeamSearchWindowedDecoder::from_dem(D3_DEM, config, uf_edge_factory, Some(uf_factory)); + assert!(dec.is_ok()); + assert!(dec.unwrap().num_windows() > 0); + } + + #[test] + fn test_beam_k1_matches_sandwich() { + // K=1 beam with no perturbation should match the sandwich decoder. + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let wconfig = WindowedConfig { + step_size: 3, + buffer_size: 3, + commit_weight_max: 2.5, + ..Default::default() + }; + + // Sandwich + let mut sandwich = + SandwichWindowedDecoder::from_dem(D3_DEM, wconfig, uf_edge_factory, uf_factory) + .unwrap(); + + // Beam K=1 + let bconfig = BeamSearchConfig { + window: wconfig, + beam_width: 1, + perturbation_sigma: 0.0, + seed: 42, + }; + let mut beam = + BeamSearchWindowedDecoder::from_dem(D3_DEM, bconfig, uf_edge_factory, Some(uf_factory)) + .unwrap(); + + let syn = vec![0u8; graph.num_detectors]; + let sw_obs = sandwich.decode_to_observables(&syn).unwrap(); + let bm_obs = beam.decode_to_observables(&syn).unwrap(); + assert_eq!( + sw_obs, bm_obs, + "K=1 beam should match sandwich on zero syndrome" + ); + } + + #[test] + fn test_beam_search_no_errors() { + let graph = DemMatchingGraph::from_dem_str(D3_DEM).unwrap(); + let config = BeamSearchConfig { + window: WindowedConfig { + step_size: 3, + buffer_size: 3, + ..Default::default() + }, + beam_width: 3, + perturbation_sigma: 0.5, + seed: 42, + }; + let mut dec = + BeamSearchWindowedDecoder::from_dem(D3_DEM, config, uf_edge_factory, Some(uf_factory)) + .unwrap(); + let obs = dec.decode_to_observables(&vec![0u8; graph.num_detectors]); + assert!(obs.is_ok()); + assert_eq!(obs.unwrap(), 0); + } +} diff --git a/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs b/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs new file mode 100644 index 000000000..d7bec84d7 --- /dev/null +++ b/crates/pecos-uf-decoder/tests/cross_decoder_tests.rs @@ -0,0 +1,118 @@ +// 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-decoder comparison: UF vs itself (consistency checks). +//! +//! We can't depend on pecos-pymatching here (C++ build), but we CAN +//! verify that UF + ensemble composed of UF decoders all agree. + +use pecos_decoder_core::ObservableDecoder; +use pecos_decoder_core::dem::DemMatchingGraph; +use pecos_decoder_core::ensemble::EnsembleDecoder; +use pecos_uf_decoder::{UfDecoder, UfDecoderConfig}; + +const D3_DEM: &str = + include_str!("../../../examples/surface_code_circuits/surface_code_d3_z_stim.dem"); + +/// An ensemble of 3 identical UF decoders must agree with a single UF decoder +/// on every syndrome (since all members produce the same result, the majority +/// vote is identical to any single member). +#[test] +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 members: Vec> = (0..3) + .map(|_| { + Box::new(UfDecoder::from_matching_graph( + &graph, + UfDecoderConfig::default(), + )) as Box + }) + .collect(); + let mut ensemble = EnsembleDecoder::new(members); + + let mut rng = fastrand::Rng::with_seed(123); + for _ in 0..500 { + let mut syndrome = vec![0u8; graph.num_detectors]; + for s in &mut syndrome { + if rng.f64() < 0.05 { + *s = 1; + } + } + + let single_obs = single.decode_to_observables(&syndrome).unwrap(); + let ensemble_obs = ensemble.decode_to_observables(&syndrome).unwrap(); + assert_eq!( + single_obs, ensemble_obs, + "Ensemble of identical decoders diverged from single decoder" + ); + } +} + +/// Verify the UF decoder produces deterministic results across runs. +#[test] +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 rng = fastrand::Rng::with_seed(999); + for _ in 0..200 { + let mut syndrome = vec![0u8; graph.num_detectors]; + for s in &mut syndrome { + if rng.f64() < 0.08 { + *s = 1; + } + } + + let r1 = dec1.decode_to_observables(&syndrome).unwrap(); + let r2 = dec2.decode_to_observables(&syndrome).unwrap(); + assert_eq!(r1, r2, "Two identical decoders gave different results"); + } +} + +/// Test `MatchingDecoder` consistency: `decode_with_matching` and `decode_to_observables` +/// must agree on the observable mask. +#[test] +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 rng = fastrand::Rng::with_seed(777); + for _ in 0..200 { + let mut syndrome = vec![0u8; graph.num_detectors]; + for s in &mut syndrome { + if rng.f64() < 0.06 { + *s = 1; + } + } + + let obs = dec.decode_to_observables(&syndrome).unwrap(); + + // Reset and decode again with matching + let (match_obs, edges) = dec.decode_with_matching(&syndrome).unwrap(); + + assert_eq!( + obs, match_obs, + "ObservableDecoder and MatchingDecoder disagree on observable mask" + ); + + // If there are matched edges, they should be valid indices + for &e in &edges { + assert!(e < dec.num_edges(), "Edge index {e} out of range"); + } + } +} diff --git a/crates/pecos-uf-decoder/tests/integration_tests.rs b/crates/pecos-uf-decoder/tests/integration_tests.rs new file mode 100644 index 000000000..985a179b9 --- /dev/null +++ b/crates/pecos-uf-decoder/tests/integration_tests.rs @@ -0,0 +1,173 @@ +// 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 UF decoder using realistic surface code DEMs. + +use pecos_decoder_core::ObservableDecoder; +use pecos_decoder_core::dem::DemMatchingGraph; +use pecos_uf_decoder::{UfDecoder, UfDecoderConfig}; + +/// Distance-3 rotated surface code, Z basis, 3 rounds of syndrome extraction. +/// This is a realistic DEM with 24 detectors and ~100 error mechanisms. +/// Non-decomposed (for matching graph extraction). +const D3_SURFACE_CODE_DEM: &str = + include_str!("../../../examples/surface_code_circuits/surface_code_d3_z_stim.dem"); + +/// Check the decoder initializes correctly from a real surface code DEM. +#[test] +fn test_real_dem_construction() { + let graph = DemMatchingGraph::from_dem_str(D3_SURFACE_CODE_DEM).unwrap(); + assert!( + graph.num_detectors >= 20, + "Expected 20+ detectors, got {}", + graph.num_detectors + ); + assert!( + graph.edges.len() >= 10, + "Expected 10+ edges, got {}", + graph.edges.len() + ); + + let dec = UfDecoder::from_matching_graph(&graph, UfDecoderConfig::default()); + assert_eq!(dec.num_detectors(), graph.num_detectors); + assert_eq!(dec.num_edges(), graph.edges.len()); +} + +/// Decode the trivial (no-error) syndrome -- should always give observable 0. +#[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 syndrome = vec![0u8; graph.num_detectors]; + assert_eq!(dec.decode_syndrome(&syndrome), 0); +} + +/// Decode single-defect syndromes (one detector triggered). +/// Each should produce a valid correction (not panic, not hang). +#[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()); + + for d in 0..graph.num_detectors { + let mut syndrome = vec![0u8; graph.num_detectors]; + syndrome[d] = 1; + // Should not panic or hang. Observable is either 0 or 1. + let obs = dec.decode_syndrome(&syndrome); + assert!( + obs <= 1, + "Observable mask {obs} too large for single-observable DEM" + ); + } +} + +/// Decode syndromes with two adjacent defects. +/// For each edge in the matching graph, set both endpoint detectors and decode. +#[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()); + + for edge in &graph.edges { + let mut syndrome = vec![0u8; graph.num_detectors]; + syndrome[edge.node1 as usize] = 1; + if let Some(n2) = edge.node2 { + syndrome[n2 as usize] = 1; + } + let obs = dec.decode_syndrome(&syndrome); + assert!(obs <= 1, "Observable mask {obs} too large"); + } +} + +/// Stress test: decode many random syndromes with even number of defects. +/// Verify the decoder never panics or hangs, and results are in range. +#[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 rng = fastrand::Rng::with_seed(42); + + for _ in 0..1000 { + let mut syndrome = vec![0u8; graph.num_detectors]; + let mut num_defects = 0; + for s in &mut syndrome { + if rng.f64() < 0.05 { + *s = 1; + num_defects += 1; + } + } + // Ensure even number of defects (valid syndrome for surface codes). + if num_defects % 2 != 0 && !syndrome.is_empty() { + // Flip a random detector to make it even. + let idx = rng.usize(..syndrome.len()); + syndrome[idx] ^= 1; + } + + let obs = dec.decode_syndrome(&syndrome); + assert!(obs <= 1, "Observable mask {obs} too large"); + } +} + +/// Test via the `ObservableDecoder` trait (the actual interface used in production). +#[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 syndrome = vec![0u8; graph.num_detectors]; + let result = dec.decode_to_observables(&syndrome); + assert!(result.is_ok()); + assert_eq!(result.unwrap(), 0); +} + +/// Test `MatchingDecoder` trait on real DEM. +#[test] +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()); + + // Two adjacent defects. + let edge = &graph.edges[0]; + let mut syndrome = vec![0u8; graph.num_detectors]; + syndrome[edge.node1 as usize] = 1; + if let Some(n2) = edge.node2 { + syndrome[n2 as usize] = 1; + } + + let (obs, _matched_edges) = dec.decode_with_matching(&syndrome).unwrap(); + assert!(obs <= 1); + // Predecoder may handle simple cases without tracking edges. + assert!(obs <= 1); +} + +/// Buffer reuse stress test: alternate between zero and non-zero syndromes. +/// Catches bugs where state leaks between shots. +#[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 zero_syndrome = vec![0u8; graph.num_detectors]; + + let mut defect_syndrome = vec![0u8; graph.num_detectors]; + defect_syndrome[0] = 1; + + for _ in 0..500 { + // Zero syndrome must always give 0. + assert_eq!( + dec.decode_syndrome(&zero_syndrome), + 0, + "Buffer leak: non-zero after defect" + ); + // Defect syndrome should give a consistent result. + let _ = dec.decode_syndrome(&defect_syndrome); + } +} diff --git a/crates/pecos/src/lib.rs b/crates/pecos/src/lib.rs index 44588d851..95244ce20 100644 --- a/crates/pecos/src/lib.rs +++ b/crates/pecos/src/lib.rs @@ -57,7 +57,7 @@ pub mod quantum { }; pub use pecos_quantum::{ Attribute, Circuit, CircuitMut, CustomGateError, DagCircuit, DagWouldCycleError, Gate, - GateHandle, GateType, GateView, QubitId, Tick, TickCircuit, + GateHandle, GateType, GateView, QubitId, Tick, TickCircuit, TickGateError, }; pub use pecos_quantum::{F2Matrix, PauliSequence, PauliSet, PauliStabilizerGroup}; } diff --git a/design/ENGINES_ARCHITECTURE.md b/design/ENGINES_ARCHITECTURE.md deleted file mode 100644 index 72bfd9133..000000000 --- a/design/ENGINES_ARCHITECTURE.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/engines-architecture.md`. diff --git a/design/OPERATOR_TYPE_SYSTEM.md b/design/OPERATOR_TYPE_SYSTEM.md deleted file mode 100644 index 532d2e099..000000000 --- a/design/OPERATOR_TYPE_SYSTEM.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/operator-type-system.md`. diff --git a/design/QIS_ARCHITECTURE.md b/design/QIS_ARCHITECTURE.md deleted file mode 100644 index 03331b6a2..000000000 --- a/design/QIS_ARCHITECTURE.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/qis-architecture.md`. diff --git a/design/STABILIZER_CODE_ARCHITECTURE.md b/design/STABILIZER_CODE_ARCHITECTURE.md deleted file mode 100644 index b121b1859..000000000 --- a/design/STABILIZER_CODE_ARCHITECTURE.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/stabilizer-code-architecture.md`. diff --git a/design/circuit-representations.md b/design/circuit-representations.md deleted file mode 100644 index 746dd7601..000000000 --- a/design/circuit-representations.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/circuit-representations.md`. diff --git a/design/pecos-cuquantum-plan.md b/design/pecos-cuquantum-plan.md deleted file mode 100644 index 0a15b3003..000000000 --- a/design/pecos-cuquantum-plan.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/pecos-cuquantum-plan.md`. diff --git a/design/proposals/byte_message_api_cleanup.md b/design/proposals/byte_message_api_cleanup.md deleted file mode 100644 index 4725b8e6a..000000000 --- a/design/proposals/byte_message_api_cleanup.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/proposals/byte_message_api_cleanup.md`. diff --git a/design/proposals/slr-ast.md b/design/proposals/slr-ast.md deleted file mode 100644 index 2b1cd8130..000000000 --- a/design/proposals/slr-ast.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/proposals/slr-ast.md`. diff --git a/design/proposals/slr-qubit-allocators.md b/design/proposals/slr-qubit-allocators.md deleted file mode 100644 index 771ec8d25..000000000 --- a/design/proposals/slr-qubit-allocators.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/proposals/slr-qubit-allocators.md`. diff --git a/design/python-classical-interpreter-suspected-bugs.md b/design/python-classical-interpreter-suspected-bugs.md deleted file mode 100644 index 4cc8cbbff..000000000 --- a/design/python-classical-interpreter-suspected-bugs.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/python-classical-interpreter-suspected-bugs.md`. diff --git a/design/rust-phir-classical-interpreter.md b/design/rust-phir-classical-interpreter.md deleted file mode 100644 index b48a2d571..000000000 --- a/design/rust-phir-classical-interpreter.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/rust-phir-classical-interpreter-spec.md`. diff --git a/design/stn_2d_geometry_backend.md b/design/stn_2d_geometry_backend.md deleted file mode 100644 index 26b9552f0..000000000 --- a/design/stn_2d_geometry_backend.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/stab-tn/2d-geometry-backend.md`. diff --git a/design/stn_orthogonal_directions.md b/design/stn_orthogonal_directions.md deleted file mode 100644 index 2c1ebef8c..000000000 --- a/design/stn_orthogonal_directions.md +++ /dev/null @@ -1,3 +0,0 @@ -# Moved to pecos-docs vault - -This document has been moved to `~/Repos/pecos-docs/design/stab-tn/orthogonal-directions.md`. diff --git a/docs/README.md b/docs/README.md index fede603fb..9eab704d6 100644 --- a/docs/README.md +++ b/docs/README.md @@ -115,6 +115,7 @@ PECOS is available in multiple languages: This documentation is organized to help you get the most out of PECOS: - **[User Guide](user-guide/getting-started.md)**: Tutorials and guides for using PECOS +- **[PECOS Concepts](user-guide/pecos-concepts.md)**: Core terminology for detectors, observables, tracked Paulis, gates, and noise - **[Concepts](concepts/index.md)**: Physics, math, and algorithms behind the simulators - **API Reference**: Detailed API documentation - [Python API](https://quantum-pecos.readthedocs.io/en/latest/) diff --git a/docs/development/DEVELOPMENT.md b/docs/development/DEVELOPMENT.md index 57dac9ab4..8ecee016e 100644 --- a/docs/development/DEVELOPMENT.md +++ b/docs/development/DEVELOPMENT.md @@ -28,15 +28,14 @@ For developers who want to contribute or modify PECOS: 1. Make sure you have [Python](https://www.python.org/downloads/) and [Rust](https://www.rust-lang.org/tools/install) installed for your system. -2. Install all dev tools with a single command: +2. Install the pre-clone dev tools from crates.io: ```sh - cargo install --locked uv just pecos + cargo install --locked uv just ``` This installs: - `uv` - Python package manager - `just` - Command runner for build tasks - - `pecos` - PECOS dev tools (llvm, cuda, rust, python commands) 3. Clone the repository: ```sh @@ -44,7 +43,14 @@ For developers who want to contribute or modify PECOS: cd PECOS ``` -4. Create the development environment: +4. Install the PECOS developer CLI from the repo: + ```sh + cargo install --path crates/pecos-cli + ``` + + This installs the `pecos` binary (llvm, cuda, cuquantum, rust, python, deps commands). + +5. Create the development environment: ```sh uv sync ``` @@ -61,7 +67,7 @@ For developers who want to contribute or modify PECOS: Combine groups with multiple `--group` flags (e.g. `uv sync --group examples --group cuda`). -5. **LLVM 14 Setup (Required for LLVM IR/QIS Support)** +6. **LLVM 14 Setup (Required for LLVM IR/QIS Support)** PECOS requires LLVM version 14 for LLVM IR execution features. @@ -73,7 +79,7 @@ For developers who want to contribute or modify PECOS: For detailed installation instructions for all platforms (macOS, Linux, Windows), see the [**LLVM Setup Guide**](../user-guide/llvm-setup.md). -6. You may wish to explicitly activate the environment for development. To do so: +7. You may wish to explicitly activate the environment for development. To do so: === "Linux/Mac" ```sh @@ -85,24 +91,24 @@ For developers who want to contribute or modify PECOS: .\.venv\Scripts\activate ``` -6. Build the project in editable mode +8. Build the project in editable mode ```sh just build ``` Other build options: `just build-release` (optimized), `just build-native` (optimized for your CPU). -7. Run all Python and Rust tests: +9. Run all Python and Rust tests: ```sh just test ``` Note: Make sure you have run a build command before running tests. -8. Run linters using pre-commit (after [installing it](https://pre-commit.com/)) to make sure all everything is properly linted/formated - ```sh - just lint - ``` +10. Run linters using pre-commit (after [installing it](https://pre-commit.com/)) to make sure all everything is properly linted/formated + ```sh + just lint + ``` -9. To deactivate your development venv: +11. To deactivate your development venv: ```sh deactivate ``` @@ -139,7 +145,7 @@ PECOS uses `~/.pecos/` to store external dependencies and build artifacts that c ``` ~/.pecos/ ├── llvm/ # LLVM-14 installation (for QIR/LLVM IR execution) -├── deps/ # Downloaded C++ dependencies (Stim, QuEST, Qulacs, etc.) +├── deps/ # Downloaded C++ dependencies (Stim, etc.) └── cache/ # Build artifacts and intermediate files ``` diff --git a/docs/development/ast-infrastructure.md b/docs/development/ast-infrastructure.md index 97c04d192..67496cb84 100644 --- a/docs/development/ast-infrastructure.md +++ b/docs/development/ast-infrastructure.md @@ -25,6 +25,8 @@ Converts compiled Guppy programs (HUGR format) to SLR-AST for analysis and code **Key functions:** ```python +from guppylang import guppy +from guppylang.std.quantum import h, measure, qubit from pecos.circuit_converters.hugr_to_ast import guppy_to_ast, hugr_to_ast @@ -145,9 +147,21 @@ print(qasm) ### Serialization Round-trip ```python +from guppylang import guppy +from guppylang.std.quantum import h, measure, qubit +from pecos.circuit_converters.hugr_to_ast import guppy_to_ast from pecos.slr.ast.serialize import ast_to_json, json_to_ast from pecos.slr.ast.compare import ast_equal + +@guppy +def my_circuit() -> bool: + q = qubit() + h(q) + return measure(q) + + +ast = guppy_to_ast(my_circuit) json_str = ast_to_json(ast) restored = json_to_ast(json_str) assert ast_equal(ast, restored) diff --git a/docs/development/foreign-plugins.md b/docs/development/foreign-plugins.md index 3d4295ae5..041629656 100644 --- a/docs/development/foreign-plugins.md +++ b/docs/development/foreign-plugins.md @@ -98,6 +98,7 @@ class MyDecoder: # Wrap and use decoder = pecos_rslib.PyForeignDecoder(MyDecoder(100, 50)) +syndrome_bytes = bytes(100) result = decoder.decode(syndrome_bytes) ``` @@ -388,7 +389,7 @@ printf("Passed %u/%u tests\n", report.tests_passed, report.tests_run); ### From Rust -```rust +```rust,ignore let report = pecos_foreign::conformance::run_conformance_tests(&mut sim); assert!(report.all_passed()); ``` @@ -438,10 +439,11 @@ When using foreign simulators with pecos-neo, the `gate_support` module (behind `neo` feature flag) automatically configures the `CircuitRunner` decomposition based on what the foreign simulator supports: -```rust +```rust,ignore use pecos_foreign::gate_support::configure_runner_for_foreign; -let sim = ForeignSimulator::new(handle, vtable); +let sim = unsafe { ForeignSimulator::new(handle, vtable, num_qubits) } + .expect("foreign simulator vtable must match PECOS"); let mut runner = configure_runner_for_foreign(&sim); // If sim supports rotations: runner uses RX, RZ, RZZ natively // Otherwise: Clifford-only, everything decomposes into {SZ, H, CX} diff --git a/docs/user-guide/circuit-representation.md b/docs/user-guide/circuit-representation.md index 6a69fcdd9..291ab6d4b 100644 --- a/docs/user-guide/circuit-representation.md +++ b/docs/user-guide/circuit-representation.md @@ -245,8 +245,9 @@ Gates can have arbitrary metadata attached: # Multiple metadata entries circuit.cx([(0, 1)]).meta("duration_ns", 50) - # Measurements break the chain but still support metadata - circuit.mz([0]).meta("basis", "Z") + # Measurements return refs (not the circuit), so chain separately + circuit.mz([0]) + circuit.meta("basis", "Z") ``` === ":fontawesome-brands-rust: Rust" @@ -261,8 +262,9 @@ Gates can have arbitrary metadata attached: // Multiple metadata entries circuit.cx(&[(0, 1)]).meta("duration_ns", Attribute::Int(50)); - // Measurements break the chain but still support metadata - circuit.mz(&[0]).meta("basis", Attribute::String("Z".into())); + // Measurements return refs (not &mut Self), so chain separately + circuit.mz(&[0]); + circuit.meta("basis", Attribute::String("Z".into())); ``` ### Circuit Analysis @@ -272,7 +274,10 @@ Gates can have arbitrary metadata attached: from pecos.quantum import DagCircuit circuit = DagCircuit() - circuit.h([0]).cx([(0, 1)]).h([1]).cx([(1, 2)]).mz([0]).mz([1]).mz([2]) + circuit.h([0]).cx([(0, 1)]).h([1]).cx([(1, 2)]) + circuit.mz([0]) + circuit.mz([1]) + circuit.mz([2]) # Basic metrics print(f"Total gates: {circuit.gate_count()}") @@ -391,10 +396,12 @@ A time-sliced circuit representation where gates are organized into discrete tim print(f"Number of ticks: {circuit.num_ticks()}") print(f"Total gates: {circuit.gate_count()}") + print(f"Gate batches: {circuit.gate_batch_count()}") ``` === ":fontawesome-brands-rust: Rust" ```rust + use pecos::core::Gate; use pecos::quantum::TickCircuit; let mut circuit = TickCircuit::new(); @@ -410,6 +417,7 @@ A time-sliced circuit representation where gates are organized into discrete tim println!("Number of ticks: {}", circuit.num_ticks()); println!("Total gates: {}", circuit.gate_count()); + println!("Gate batches: {}", circuit.gate_batch_count()); ``` ### Qubit Conflict Detection @@ -440,7 +448,9 @@ TickCircuit prevents scheduling conflicting gates in the same tick: // tick.cx(&[(0, 1)]); // Use try_add_gate for fallible operations - if let Err(e) = tick.try_add_gate(Gate::cx(&[(0, 1)])) { + if let Err(pecos::quantum::TickGateError::QubitConflict(e)) = + tick.try_add_gate(Gate::cx(&[(0, 1)])) + { println!("Conflict on qubits: {:?}", e.conflicting_qubits); } ``` @@ -646,7 +656,7 @@ A directed acyclic graph with topological ordering and cycle prevention: | `cx(pairs)`, `szz(pairs)`, `rzz(theta, pairs)` | Two-qubit gates | | `mz(qubits)`, `pz(qubits)` | Measurement and preparation | | `meta(key, value)` | Attach metadata to last gate | -| `gate_count()`, `depth()`, `width()` | Circuit metrics | +| `gate_count()`, `gate_node_count()`, `depth()`, `width()` | Circuit metrics | | `qubits()` | List of qubits used | | `topological_order()` | Gates in dependency order | | `layers()` | Iterator over parallel gate layers | @@ -659,7 +669,9 @@ A directed acyclic graph with topological ordering and cycle prevention: | `new()` | Create empty circuit | | `tick()` | Start a new time step | | `num_ticks()` | Number of time steps | -| `gate_count()` | Total gates across all ticks | +| `gate_count()` | Total gate applications across all ticks | +| `gate_batch_count()` | Total stored compatible gate batches across all ticks | +| `gate_batches()` | Stored gate batches with tick indices | | `set_meta(key, value)` | Circuit-level metadata | ### DAG Methods diff --git a/docs/user-guide/cli.md b/docs/user-guide/cli.md index 763badcd6..660b064ce 100644 --- a/docs/user-guide/cli.md +++ b/docs/user-guide/cli.md @@ -108,8 +108,6 @@ Compiled Features: [x] selene - Selene QIS runtime [x] wasm - WebAssembly foreign objects [ ] llvm - LLVM/QIS compilation - [ ] quest - QuEST simulator backend - [ ] qulacs - Qulacs simulator backend Simulators: statevector - Full quantum state simulation (default) diff --git a/docs/user-guide/cuda-setup.md b/docs/user-guide/cuda-setup.md index acac008bf..c8f90ddcd 100644 --- a/docs/user-guide/cuda-setup.md +++ b/docs/user-guide/cuda-setup.md @@ -319,7 +319,6 @@ pip install quantum-pecos | Simulator | Hardware | Qubits | Gates | Speed | Installation | |-----------|----------|--------|-------|-------|--------------| | StateVec (CPU) | Any | ~25 | All | Baseline | Easy | -| Qulacs (CPU) | Any | ~28 | All | 2-3x faster | Easy | | CuStateVec (Python) | NVIDIA GPU | ~30 | All | 10-50x faster | Medium | | CudaStateVec (Rust) | NVIDIA GPU | ~30 | All | 10-50x faster | Complex | | CudaStabilizer (Rust) | NVIDIA GPU | 1000s | Clifford only | Very fast | Complex | @@ -354,18 +353,6 @@ The Rust bindings provide direct cuQuantum integration without Python package de - Integration with quantum-pecos's HybridEngine - Reproducible simulations with seed support -### Rust GPU Simulators (QuEST) - -**Status**: Limited Support (CPU-only with CUDA 13) - -- **Engine**: QuEST (Quantum Exact Simulation Toolkit) -- **CUDA Version**: Requires CUDA 11 or 12 (incompatible with CUDA 13) -- **Issue**: QuEST uses deprecated `thrust::unary_function` and `thrust::binary_function` classes that were removed in modern CUDA/Thrust versions -- **Workaround**: Automatically falls back to CPU-only QuEST build -- **Impact**: Minimal - use CudaStateVec or Python CuStateVec instead - -The Rust QuEST simulator is currently incompatible with CUDA 13 due to deprecated thrust classes. - ## Rust cuQuantum Bindings Setup To use the Rust-based CUDA simulators (CudaStateVec, CudaStabilizer), you need: @@ -377,6 +364,16 @@ To use the Rust-based CUDA simulators (CudaStateVec, CudaStabilizer), you need: ### Installing cuQuantum SDK +**Recommended:** use the `pecos` CLI to download and install into `~/.pecos/deps/cuquantum/`: + +```bash +pecos install cuquantum +``` + +This mirrors the LLVM setup flow and is the same pattern used by `pecos install llvm` / `pecos install cuda`. The PECOS build system then picks up the install automatically — no environment variables to set. + +**Manual install (if you already have cuQuantum or need a specific version):** + 1. Download from [NVIDIA cuQuantum](https://developer.nvidia.com/cuquantum-sdk) 2. Extract to a known location (e.g., `/opt/nvidia/cuquantum`) 3. Set environment variables: @@ -511,5 +508,3 @@ To use GPU simulators in PECOS: - **For stabilizer simulations**: Use CudaStabilizer (Rust) for 1000s of qubits - **For reproducibility**: Rust bindings have full seed support - **For density matrices**: Use CuDensityMat (Rust) for open quantum systems - -**Note**: If you see warnings about QuEST GPU compilation failing, this is expected with CUDA 13 and does not affect the cuQuantum-based simulators. diff --git a/docs/user-guide/fault-catalog.md b/docs/user-guide/fault-catalog.md new file mode 100644 index 000000000..f5a7e2f18 --- /dev/null +++ b/docs/user-guide/fault-catalog.md @@ -0,0 +1,590 @@ +# Fault Catalog and Measurement Sampling + +## Quick start + +If you have a surface code and want to simulate noisy measurements: + + +```python +from pecos.qec.surface import SurfacePatch +from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model +from pecos_rslib_exp import sim_neo, meas_sampling, depolarizing + +patch = SurfacePatch.create(distance=3) +tc = _build_surface_tick_circuit_for_native_model(patch, num_rounds=6, basis="Z") + +result = ( + sim_neo(tc) + .quantum(meas_sampling()) + .noise(depolarizing().p1(0.001).p2(0.01).p_meas(0.005).p_prep(0.005)) + .shots(10000) + .seed(42) + .run() +) + +# result[shot] gives measurement outcomes for each shot +print(f"{len(result)} shots, {len(result[0])} measurements each") +``` + +If you want to inspect what faults are possible in that circuit: + + +```python +from pecos_rslib_exp import fault_catalog + +# Build structural catalog (no noise commitment): +catalog = fault_catalog(tc) +print(f"{len(catalog)} fault locations") + +# Parameterize to get probabilities: +catalog.with_noise(p1=0.001, p2=0.01, p_meas=0.005, p_prep=0.005) +``` + +The rest of this tutorial uses a small hand-built circuit to explain the +concepts. Everything works the same way with surface codes or any other +`TickCircuit`. + +Note: when using circuit builders like `SurfacePatch`, the detector and +observable metadata is set automatically. The hand-built examples below +set metadata explicitly via `set_meta` -- you normally don't need to write +JSON strings by hand. + +## Core model + +- Each `FaultLocation` is an independent physical fault mechanism (one per + noisy gate in the circuit). +- Each location has one or more `FaultAlternative`s (e.g., X/Y/Z for a + single-qubit depolarizing channel, 15 alternatives for two-qubit). +- When the location fires, exactly one alternative is chosen uniformly. +- Each alternative records which measurements, detectors, observables, and + tracked Paulis it flips. +- Multi-fault effects combine by XOR parity. + +## Structural vs Parameterized Catalogs + +A fault catalog can be built in two ways: + +**Structural (no noise):** includes all fault locations for all gate types. +Probabilities are zero. Use this for topology queries, fault anatomy +exploration, or as a reusable base for parameter sweeps. + +**Parameterized (with noise):** fills in probabilities based on a stochastic +noise model. Use this for sampling, decoding, and probability-weighted queries. + + +```python +from pecos import Z +from pecos.quantum import TickCircuit +from pecos_rslib_exp import depolarizing, fault_catalog + +circuit = TickCircuit() +circuit.tick().h([0]) +circuit.tick().mz([0]) + +circuit.set_meta("num_measurements", "1") +circuit.set_meta("detectors", '[{"records":[-1]}]') +circuit.set_meta("observables", '[{"records":[-1]}]') + +# Structural -- all locations, zero probabilities: +catalog = fault_catalog(circuit) + +# Parameterize with noise: +catalog.with_noise(p1=0.03, p2=0.0, p_meas=0.01, p_prep=0.0) + +# Or one-shot (structural + parameterize in one call): +catalog = fault_catalog(circuit, p1=0.03, p2=0.0, p_meas=0.01, p_prep=0.0) +``` + +The circuit must have detector and observable metadata (`num_measurements`, +`detectors`, `observables`). The catalog uses this metadata to map raw +measurement flips into detector and observable flips. Without metadata, +structural fields like `affected_detectors` will be empty, but +`affected_measurements` are still computed from Pauli propagation. + +## Re-parameterization + +The expensive work (Pauli propagation, detector mapping) is done once during +construction. Changing noise is cheap -- it just updates probability fields: + + +```python +catalog = fault_catalog(circuit) + +# Sweep noise parameters without rebuilding the catalog: +for p in [0.001, 0.005, 0.01, 0.05]: + catalog.with_noise(p1=p * 0.1, p2=p, p_meas=p * 0.5, p_prep=p * 0.5) + # ... decode, sample, analyze ... + +# Independent copy for parallel comparison: +catalog_a = catalog.parameterized(p1=0.001, p2=0.01, p_meas=0.005, p_prep=0.005) +catalog_b = catalog.parameterized(p1=0.01, p2=0.1, p_meas=0.05, p_prep=0.05) +``` + +Note: decoders and samplers built from a catalog are snapshots. They read +probabilities at construction time. Re-parameterizing the catalog does NOT +update existing decoders or plans. + +The returned object is sequence-like: + + +```python +print(len(catalog)) +print(catalog[0]) +print(catalog[-1]) + +for location in catalog: + print(location.tick, location.gate_type, location.channel) +``` + +It also exposes the underlying locations list: + +```python +locations = catalog.locations +``` + +## Location Fields + +Each `FaultLocation` represents one physical fault mechanism. + +| Field | Meaning | +|---|---| +| `tick` | Tick index in the `TickCircuit` | +| `gate_index` | Gate index within the tick | +| `gate_type` | Gate name, such as `"H"`, `"CX"`, or `"MZ"` | +| `qubits` | Qubits acted on by this gate | +| `channel` | `"p1"`, `"p2"`, `"p_meas"`, or `"p_prep"` | +| `channel_probability` | Total probability the mechanism fires (0 if unparameterized) | +| `no_fault_probability` | `1 - channel_probability` | +| `num_alternatives` | Number of alternatives at this location, `k_i` | +| `faults` | List of `FaultAlternative` objects | + +Example: + +```python +for loc in catalog: + print( + loc.tick, + loc.gate_type, + loc.channel, + loc.channel_probability, + loc.no_fault_probability, + loc.num_alternatives, + ) +``` + +The catalog includes locations with nonzero channel probability even when all +alternatives have empty detector/observable effects. This is necessary for +correct probability accounting. + +## Alternative Fields + +Each `FaultAlternative` is one possible outcome when its parent location fires. + +| Field | Meaning | +|---|---| +| `kind` | `"pauli"`, `"measurement_flip"`, or `"prep_flip"` | +| `pauli` | A PECOS `PauliString` for Pauli faults, or `None` | +| `measurements` | Raw measurement indices flipped | +| `detectors` | Detector indices flipped | +| `observables` | Observable indices flipped | +| `tracked_paulis` | Tracked Pauli indices flipped | +| `conditional_probability` | `1 / k_i` (structural, does not depend on noise) | +| `absolute_probability` | `p_i / k_i` (0 if unparameterized) | +| `channel_probability` | Same `p_i` as the parent location | + +The four effect fields (`measurements`, `detectors`, `observables`, +`tracked_paulis`) are structural -- they depend on the circuit topology, not the +noise model. They are populated during construction and never change when +noise is re-parameterized. + +Example: + +```python +for loc in catalog: + for fault in loc.faults: + print(f" {fault.kind}: {fault.pauli}") + print(f" measurements: {fault.measurements}") + print(f" detectors: {fault.detectors}") + print(f" observables: {fault.observables}") + print(f" tracked_paulis: {fault.tracked_paulis}") +``` + +`fault.absolute_probability` is local to one fault location. It is not the +probability of "this alternative and no other faults in the circuit." + +## Probability Semantics + +Understanding probabilities matters when you are building decoders, computing +thresholds, or verifying that a noise model produces the expected error rates. + +For location `i`: + +```text +p_i = location.channel_probability +k_i = location.num_alternatives +P(no fault at i) = 1 - p_i +P(specific alternative at i) = p_i / k_i +``` + +For a full-circuit configuration: + +```text +configuration_probability + = product selected alternatives (p_i / k_i) + * product unselected locations (1 - p_i) +``` + +For a single selected alternative at location `i`, the full event probability is: + +```python +selected_location_index = 0 +fault = catalog[selected_location_index].faults[0] + +event_probability = fault.absolute_probability +for j, loc in enumerate(catalog.locations): + if j != selected_location_index: + event_probability *= loc.no_fault_probability +``` + +## Lazy k-Fault Configurations + +Use `catalog.fault_configurations(k)` to enumerate every way exactly `k` +faults can occur simultaneously. This is the foundation for building lookup +decoders, computing truncated ML tables, and analyzing multi-fault error +patterns. + +For `k = 0`, the iterator yields exactly one no-fault configuration. Its +probability is the product of every location's `no_fault_probability`: + +```python +configs = list(catalog.fault_configurations(0)) +assert len(configs) == 1 + +no_fault = configs[0] +assert no_fault.location_indices == [] +assert no_fault.alternative_indices == [] +assert no_fault.measurements == [] +assert no_fault.detectors == [] +assert no_fault.observables == [] +assert no_fault.selected_probability == 1.0 + +expected = 1.0 +for loc in catalog.locations: + expected *= loc.no_fault_probability + +assert abs(no_fault.configuration_probability - expected) < 1e-12 +``` + +For `k > 0`, each yielded `FaultConfiguration` has: + +| Field | Meaning | +|---|---| +| `location_indices` | Indices into `catalog.locations` | +| `alternative_indices` | Chosen alternative index for each selected location | +| `locations` | The selected `FaultLocation` objects | +| `faults` | The selected `FaultAlternative` objects | +| `measurements` | XOR-combined measurement effects | +| `detectors` | XOR-combined detector effects | +| `observables` | XOR-combined observable effects | +| `selected_probability` | Product of selected `absolute_probability` values | +| `configuration_probability` | Selected probability times no-fault probabilities for unselected locations | + +The iterator is lazy -- it yields one configuration at a time without +materializing all combinations up front: + +```python +it = catalog.fault_configurations(1) +first = next(it) + +print(first.location_indices) +print(first.alternative_indices) +print(first.detectors) +print(first.observables) +print(first.configuration_probability) +``` + +## XOR Parity + +When multiple faults occur simultaneously, their effects combine by XOR parity. +If two faults flip the same detector, that detector cancels. This is fundamental +to QEC -- it's why weight-2 errors can be undetectable even when each individual +fault triggers detectors. + +```python +for event in catalog.fault_configurations(2): + print(event.detectors, event.observables, event.configuration_probability) +``` + +This is the right behavior for detector syndromes and logical observable flips. +It is also why low-weight decoder tests should apply the selected correction and +check the residual logical by XOR. + +## Building a Small Lookup Table + +This example builds a complete lookup table in Python by exhaustively +enumerating fault configurations. This is useful for understanding the API +and for small circuits. For larger codes, use the Rust `TargetedLookupDecoder` +which searches on-demand without precomputing all syndromes. + +A lookup decoder table groups configuration probability by: + +```text +detector syndrome -> logical observable class -> probability +``` + +For a truncated table: + +```python +from collections import defaultdict + + +def add_weight(table, syndrome, logical, probability): + table[tuple(syndrome)][tuple(logical)] += probability + + +table = defaultdict(lambda: defaultdict(float)) + +for k in range(0, 3): + for event in catalog.fault_configurations(k): + add_weight( + table, + event.detectors, + event.observables, + event.configuration_probability, + ) + +decoder = {} +for syndrome, logical_weights in table.items(): + best_logical = max(logical_weights.items(), key=lambda item: item[1])[0] + decoder[syndrome] = best_logical + + +# Apply the decoder: XOR the event's logical class with the correction +def xor_sorted(a, b): + out = set(a) + for item in b: + if item in out: + out.remove(item) + else: + out.add(item) + return tuple(sorted(out)) + + +for event in catalog.fault_configurations(1): + correction = decoder[tuple(event.detectors)] + residual = xor_sorted(event.observables, correction) + print(event.detectors, residual) +``` + +## Rust API + +The Rust API lives in `pecos-qec`: + + +```rust +use pecos_qec::fault_tolerance::fault_sampler::{ + FaultCatalog, StochasticNoiseParams, +}; +use pecos_quantum::{Attribute, TickCircuit}; + +let mut circuit = TickCircuit::new(); +circuit.tick().h(&[0]); +circuit.tick().mz(&[0]); + +circuit.set_meta("num_measurements", Attribute::String("1".into())); +circuit.set_meta( + "detectors", + Attribute::String(r#"[{"records":[-1]}]"#.into()), +); +circuit.set_meta( + "observables", + Attribute::String(r#"[{"records":[-1]}]"#.into()), +); + +// Structural catalog (no noise): +let mut catalog = FaultCatalog::from_circuit(&circuit).unwrap(); + +// Parameterize: +let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, +}; +catalog.with_noise(&noise); + +// Or one-shot convenience: +// let catalog = build_fault_catalog(&circuit, &noise).unwrap(); +``` + +Iterate locations and alternatives: + + +```rust +for loc in &catalog.locations { + println!( + "tick={} gate={:?} channel={:?} p={} k={}", + loc.tick, + loc.gate_type, + loc.channel, + loc.channel_probability, + loc.num_alternatives + ); + + for fault in &loc.faults { + println!( + " {:?} dets={:?} obs={:?} tracked={:?} p_alt={}", + fault.kind, + fault.affected_detectors, + fault.affected_observables, + fault.affected_tracked_paulis, + fault.absolute_probability + ); + } +} +``` + +Iterate configurations: + + +```rust +for event in catalog.fault_configurations(2) { + println!( + "locations={:?} alternatives={:?} dets={:?} obs={:?} p={}", + event.location_indices, + event.alternative_indices, + event.affected_detectors, + event.affected_observables, + event.configuration_probability + ); +} +``` + +The Rust iterator borrows the catalog and does not materialize all +configurations up front. On an unparameterized catalog, `fault_configurations(k)` +for k > 0 yields nothing (all probabilities are zero). + +## Fault Anatomy Exploration + +The structural catalog (no noise needed) lets you explore every fault event: + + +```python +catalog = fault_catalog(circuit) + +# What faults can flip detector D0? +for loc in catalog: + for alt in loc.faults: + if 0 in alt.detectors: + print(f"D0 flipped by {alt.pauli} at {loc.gate_type}({loc.qubits})") + +# Find undetectable weight-2 logical errors: +catalog.with_noise(p1=0.01, p2=0.05, p_meas=0.01, p_prep=0.01) +for config in catalog.fault_configurations(2): + if config.observables and not config.detectors: + print(f"Undetectable: locations {config.location_indices}") +``` +```output +D0 flipped by X_0 at H([0]) +D0 flipped by Y_0 at H([0]) +D0 flipped by None at MZ([0]) +``` + +## Tracked Operators + +Tracked Paulis are Pauli strings that the catalog monitors for +anticommutation with fault events. Unlike observables, they have no +measurement records -- they are detected by forward Pauli propagation. +See [PECOS Concepts](pecos-concepts.md) for the full detector, observable, +and tracked-Pauli distinction. + +Add tracked Paulis to a circuit via `tracked_pauli`: + + +```python +tc2 = TickCircuit() +tc2.tick().h([0]) +tc2.set_meta("num_measurements", "0") +tc2.set_meta("detectors", "[]") +tc2.set_meta("observables", "[]") +# Track Z on qubit 0 -- X and Y faults after H anticommute with Z +tc2.tracked_pauli(Z(0), label="track_Z0") + +cat2 = fault_catalog(tc2, p1=0.01, p2=0.0, p_meas=0.0, p_prep=0.0) +for loc in cat2: + for alt in loc.faults: + if alt.tracked_paulis: + print(f"{alt.pauli} flips tracked Paulis {alt.tracked_paulis}") +``` +```output +X_0 flips tracked Paulis [0] +Y_0 flips tracked Paulis [0] +``` + +No measurement is needed -- the catalog detects that X and Y faults after H +anticommute with the tracked Z Pauli. This is useful for studying logical +operator propagation independently of measurement outcomes. + +## Raw Measurement Sampling + +The `meas_sampling()` backend in `sim_neo` uses the fault catalog internally +to produce raw measurement bitstrings: + +```python +from pecos_rslib_exp import sim_neo, meas_sampling, depolarizing + +result = ( + sim_neo(circuit) + .quantum(meas_sampling()) + .noise(depolarizing().p1(0.001).p2(0.01).p_meas(0.005).p_prep(0.005)) + .shots(10000) + .seed(42) + .run() +) + +# result[shot] gives measurement outcomes for each shot +for shot in range(len(result)): + measurements = list(result[shot]) +``` + +The sampling architecture: +- **Ideal values** from symbolic stabilizer simulation (respects measurement + correlations across resets) +- **Physical faults** from geometric skip sampling (O(fired events) per shot) +- Raw measurement = ideal XOR faults + +This is fast (millions of shots per second at small distances) and produces +the same measurement format as gate-by-gate stabilizer simulation. + +## Common Pitfalls + +- `fault.absolute_probability` is `p_i / k_i`, not a full-circuit event + probability. +- Empty-effect alternatives (no measurements flipped) are real -- they + represent Pauli errors that commute with subsequent measurements. They + must stay in the catalog for the correct uniform denominator. +- `catalog.fault_configurations(k)` means exactly `k` distinct physical + locations fire, not at most `k`. Only alternatives with positive + `absolute_probability` are yielded. On an unparameterized catalog, k > 0 + yields nothing. On a parameterized catalog with some channels zeroed, + locations from those channels are skipped. +- Detector and observable metadata must be correct before building the catalog. + Missing boundary detectors can make a correct decoder appear to fail. +- `with_noise()` mutates the catalog in place. Previously held Python + references to locations and faults update automatically. Decoders and + samplers do NOT update -- they are snapshots. +- The structural catalog includes ALL locations even when a channel + probability is zero. The `meas_sampling()` backend internally filters + zero-probability locations when building raw sampling mechanisms. + +## Larger Example + +The Rust example `examples/surface/d3_fault_catalog_lookup.rs` builds a +distance-3 surface-code memory experiment, walks `fault_configurations(k)` for +`k = 0..=2`, and aggregates a truncated lookup decoder table. + +Run it from the repository root: + +```bash +cargo run -p pecos-qec --example surface_d3_fault_catalog_lookup +``` diff --git a/docs/user-guide/fault-tolerance.md b/docs/user-guide/fault-tolerance.md index dabe5955f..2fff540cf 100644 --- a/docs/user-guide/fault-tolerance.md +++ b/docs/user-guide/fault-tolerance.md @@ -33,7 +33,8 @@ A code is **fault-tolerant at weight t** if no weight-t error is an undetectable ```rust use pecos_qec::{StabilizerCodeSpec, StabilizerFlipChecker, ErrorClass}; -use pecos_core::{Xs, Zs, PauliString, QuarterPhase}; +use pecos_core::{PauliString, QuarterPhase}; +use pecos_core::pauli::{Xs, Zs}; // Define a 3-qubit bit-flip code let code = StabilizerCodeSpec::builder(3) @@ -51,8 +52,7 @@ let checker = StabilizerFlipChecker::new(&code); ```rust use pecos_qec::{StabilizerCodeSpec, StabilizerFlipChecker, ErrorClass}; -use pecos_core::{Xs, Zs}; -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; let code = StabilizerCodeSpec::builder(3) .check(Zs([0, 1])) @@ -80,8 +80,7 @@ For detailed information about which stabilizers and logicals are affected: ```rust use pecos_qec::{StabilizerCodeSpec, StabilizerFlipChecker}; -use pecos_core::{Xs, Zs}; -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; let code = StabilizerCodeSpec::builder(3) .check(Zs([0, 1])).check(Zs([1, 2])) @@ -102,7 +101,7 @@ Enumerate all weight-t Pauli errors and classify each: ```rust use pecos_qec::{StabilizerCodeSpec, StabilizerFlipChecker}; -use pecos_core::{Xs, Zs}; +use pecos_core::pauli::{Xs, Zs}; let code = StabilizerCodeSpec::builder(3) .check(Zs([0, 1])).check(Zs([1, 2])) @@ -129,7 +128,7 @@ For CSS codes, you can analyze X, Y, and Z errors separately: ```rust use pecos_qec::{StabilizerCodeSpec, StabilizerFlipChecker}; -use pecos_core::{Xs, Zs}; +use pecos_core::pauli::{Xs, Zs}; let code = StabilizerCodeSpec::builder(3) .check(Zs([0, 1])).check(Zs([1, 2])) @@ -279,7 +278,7 @@ use pecos_qec::DemBuilder; // Build DEM from a fault influence map let dem = DemBuilder::new(&influence_map) - .with_noise(0.01, 0.01, 0.01, 0.01) // p1, p2, p_meas, p_init + .with_noise(0.01, 0.01, 0.01, 0.01) // p1, p2, p_meas, p_prep .with_detectors_json(detectors_json)? .with_observables_json(observables_json)? .build(); @@ -296,7 +295,7 @@ The `distance` module provides configurable distance search: ```rust use pecos_qec::{StabilizerCodeSpec, calculate_distance, DistanceSearchConfig}; -use pecos_core::{Xs, Zs}; +use pecos_core::pauli::{Xs, Zs}; let code = StabilizerCodeSpec::builder(7) .check(Xs([0, 2, 4, 6])) @@ -328,7 +327,7 @@ let result = calculate_distance(&code, &DistanceSearchConfig::with_max_weight(5) ```rust use pecos_qec::{StabilizerCodeSpec, find_min_weight_logicals_with_info, DistanceSearchConfig}; -use pecos_core::{Xs, Zs}; +use pecos_core::pauli::{Xs, Zs}; let code = StabilizerCodeSpec::builder(7) .check(Xs([0, 2, 4, 6])) @@ -357,7 +356,7 @@ When you have stabilizer generators but don't know the logical operators, `disco ```rust use pecos_qec::discover_logical_operators; -use pecos_core::pauli::constructors::Zs; +use pecos_core::pauli::Zs; // Define stabilizers for the 3-qubit bit-flip code let stabilizers = vec![Zs([0, 1]), Zs([1, 2])]; diff --git a/docs/user-guide/gate-angle-types.md b/docs/user-guide/gate-angle-types.md index baaab2430..4f29dad83 100644 --- a/docs/user-guide/gate-angle-types.md +++ b/docs/user-guide/gate-angle-types.md @@ -119,7 +119,7 @@ The `phase!` and `phase_turn!` macros wrap angles into `PhaseValue` for use with ```rust use pecos_core::{phase, phase_turn}; -use pecos_core::unitary_rep::X; +use pecos_core::unitary::X; // Global phase applied to a gate let op = phase!(pi / 4) * X(0); // e^{i*pi/4} * X diff --git a/docs/user-guide/gates.md b/docs/user-guide/gates.md index dc8e6937c..57896dbfa 100644 --- a/docs/user-guide/gates.md +++ b/docs/user-guide/gates.md @@ -1073,7 +1073,6 @@ These operations measure and then prepare the qubit in a specific eigenstate reg |-----------|---------------|-------------------|-------| | **SparseStab** | All | None | Default, fastest for QEC | | **StateVec** | All | All | Pure Rust state vector | -| **Qulacs** | All | All | High-performance C++ backend | | **CuStateVec** | All | All | GPU-accelerated (requires CUDA) | | **MPS** | All | All | Tensor network (requires CUDA) | | **PauliProp** | All | None | Error propagation tracking | diff --git a/docs/user-guide/getting-started.md b/docs/user-guide/getting-started.md index 3c75832ad..8fbeb2b16 100644 --- a/docs/user-guide/getting-started.md +++ b/docs/user-guide/getting-started.md @@ -167,6 +167,7 @@ The Python example uses a state vector simulator, which supports all quantum gat ## Next Steps - **[HUGR & Guppy Simulation](hugr-simulation.md)**: Measurement-based control flow and advanced Guppy features +- **[PECOS Concepts](pecos-concepts.md)**: Detectors, observables, tracked Paulis, gates, channels, and fault locations - **[QASM Simulation](qasm-simulation.md)**: Full QASM simulation API for existing OpenQASM code - **[Simulators](simulators.md)**: Choose the right simulation backend - **[Noise Model Builders](noise-model-builders.md)**: Add realistic noise to your simulations @@ -180,7 +181,6 @@ Most users won't need these, but they're available for specialized use cases: |---------|-----------------|-------------| | **LLVM** (Rust only) | QIR/LLVM IR execution | [LLVM Setup](llvm-setup.md) | | **CUDA** | GPU-accelerated simulation | [CUDA Setup](cuda-setup.md) | -| **QuEST** | Alternative simulator backend | `pip install quantum-pecos[all]` | !!! tip "Python users" Pre-built wheels include LLVM support—no extra setup needed. diff --git a/docs/user-guide/llvm-setup.md b/docs/user-guide/llvm-setup.md index c5fa07ffd..3a523ea88 100644 --- a/docs/user-guide/llvm-setup.md +++ b/docs/user-guide/llvm-setup.md @@ -23,7 +23,7 @@ If you don't need QIS LLVM IR/QIR execution features, you can skip LLVM installa ### Option 1: Automatic Installation (Recommended) -Use the `pecos-llvm` CLI tool 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 14.0.6: ```bash # Install LLVM 14.0.6 to ~/.pecos/deps/llvm-14/ (~400MB, ~5 minutes) @@ -110,9 +110,9 @@ cargo run -p pecos-cli -- llvm version cargo run -p pecos-cli -- llvm find ``` -## pecos-llvm CLI Reference +## `pecos llvm` CLI Reference -The `pecos llvm` CLI tool provides several useful commands: +The `pecos llvm` subcommand provides several useful commands: ### `install` @@ -214,7 +214,7 @@ LLVM_SYS_140_PREFIX = { value = "/path/to/llvm", force = true } ### Detection Priority -The `pecos-llvm` tool searches for LLVM 14 in this order: +The `pecos llvm` tooling searches for LLVM 14 in this order: 1. **Home directory:** - Windows: `~/.pecos/deps/llvm-14` @@ -244,7 +244,7 @@ The `pecos-llvm` tool searches for LLVM 14 in this order: - Uses `.7z` archives for distribution - Pure Rust extraction (no external tools required) -- Official LLVM Windows installer lacks development files - use `pecos-llvm install` or community packages +- Official LLVM Windows installer lacks development files - use `pecos install llvm` or community packages ### Security diff --git a/docs/user-guide/noise-model-builders.md b/docs/user-guide/noise-model-builders.md index ebe0a1050..02eaf751b 100644 --- a/docs/user-guide/noise-model-builders.md +++ b/docs/user-guide/noise-model-builders.md @@ -131,6 +131,17 @@ noise = ( ) ``` +### Idle Locations + +`Idle` gates are timing markers by default. They do not silently inherit +single-qubit gate noise from `p1` or `with_p1_probability(...)`. + +This is intentional: adding an idle location changes circuit timing, while +adding idle noise changes the physical noise model. To model idle decoherence, +use an API that explicitly attaches idle noise or an explicit channel to idle +locations. This keeps scheduling changes from accidentally changing the noise +model. + ## Common Noise Model Examples ### Basic Depolarizing Noise diff --git a/docs/user-guide/pecos-concepts.md b/docs/user-guide/pecos-concepts.md new file mode 100644 index 000000000..607d9aa97 --- /dev/null +++ b/docs/user-guide/pecos-concepts.md @@ -0,0 +1,141 @@ +# PECOS Concepts + +PECOS keeps a few related QEC concepts separate because they answer different +questions. The implementation can share propagation machinery underneath, but +the public API should make the distinction clear. + +## Quick Map + +| Concept | Meaning | Typical source | Decoder role | +|---|---|---|---| +| Detector | A parity check on measurement records | Measurement record metadata | Syndrome bit | +| Observable | A measured logical or experiment output | Measurement record metadata | Logical class decoded from the syndrome | +| Tracked Pauli | A Pauli operator inserted as a non-physical probe | Circuit annotation | Analysis output, ignored by ordinary DEM decoders | +| Gate | An ideal operation in a circuit | Circuit builder | No noise unless a noise model attaches it | +| Channel | A physical CPTP map, often noise | Noise model or explicit channel op | Source of stochastic or coherent faults | +| Fault location | One independent place a modeled fault can occur | Fault catalog | Unit of fault enumeration and sampling | + +## Detectors, Observables, and Tracked Operators + +**Detectors** are syndrome bits. A detector is defined by a parity expression +over measurement records, such as "the previous ancilla measurement differs +from the same check in the prior round." Decoders consume detector flips as the +syndrome. + +**Observables** are measurement-defined experiment outputs. In QEC workflows +these are often logical measurement outcomes. They are still defined through +measurement records, so they are things the experiment can observe directly or +infer from recorded measurement data. Logical error rate terminology in PECOS +continues to refer to errors in these logical observables. + +**Tracked Paulis** are Pauli strings placed at a circuit point as probes. +They are not measured by that annotation and do not become detector syndrome +bits. Fault-analysis code asks whether propagated faults anticommute with the +tracked Pauli at that point. A tracked Pauli might be a logical operator, +a stabilizer, or another tracked Pauli useful for analysis. + +Error events can therefore flip three independent kinds of output: + +- detectors: what syndrome bits changed +- observables: what measured logical or experiment outputs changed +- tracked Paulis: which tracked Paulis anticommute with the propagated fault + +Do not merge observable IDs and tracked-Pauli IDs. Observable `0` is always +observable `0`; tracked Paulis have their own ID space and their own metadata. + +## Operator Construction + +Use the most structured representation that fits the situation: + +1. Typed constructors, such as `X(0) & Z(3)`, for ordinary code. +2. Sparse strings, such as `"X0 Z3"`, for compact text input with explicit + qubit indices. +3. Dense strings, such as `"XIIZ"`, for table-like input where character + position is the qubit index. + +When writing Rust code, the constructor style is the default: + +```rust +use pecos_core::pauli::*; +use pecos_core::PauliOperator; + +let logical_x = X(0) & X(1) & X(2); +let z_probe = Z(3); + +assert_eq!(logical_x.weight(), 3); +assert!(logical_x.commutes_with(&z_probe)); +``` + +String parsing is still useful when reading checks, user input, test fixtures, +or text formats: + +```rust +use pecos_core::{PauliOperator, PauliString}; + +let stabilizer: PauliString = "Z0 Z1 Z4 Z5".parse().unwrap(); +assert_eq!(stabilizer.weight(), 4); +``` + +Python exposes the same idea. Use `X(0) & Z(3)` for inline construction, +`PauliString.from_sparse_str(...)` for explicit sparse text, and +`PauliString.from_dense_str(...)` for dense text. `PauliString.from_str(...)` +auto-detects sparse versus dense notation. + +```python +from pecos.quantum import PauliString, X, Z + +probe = X(0) & X(1) & Z(3) +from_text = PauliString.from_str("X0 X1 Z3") + +assert probe == from_text +assert probe.to_sparse_str() == "+X0 X1 Z3" +assert probe.to_dense_str() == "+XXIZ" +``` + +In Pauli-algebra contexts, `X`, `Y`, and `Z` construct `PauliString` values. +In circuit-building contexts, use circuit APIs such as `Gate.x(...)`, +`TickCircuit.tick().x(...)`, or the corresponding builder methods. + +## Gates, Channels, Noise, and Idle Locations + +A **gate** is an ideal circuit operation. A **channel** is a physical map. Noise +models attach channels to selected circuit locations. + +`Idle` is a scheduling marker unless idle noise is attached explicitly. Adding +an `Idle` gate records timing structure; it should not silently inherit +single-qubit gate noise. To model idle decoherence, use a noise model or channel +API that explicitly targets idle locations. + +This keeps two actions separate: + +- changing circuit timing: add or remove idle locations +- changing the physical noise model: attach idle noise explicitly + +## DEMs and Fault Catalogs + +PECOS detector-error models represent detector and observable effects that +ordinary decoders consume. PECOS-specific metadata can also carry tracked +Paulis for analysis, but tracked Paulis are not logical observables and +ordinary DEM decoders should ignore them. + +The fault catalog gives the most detailed per-location view: + +- `affected_measurements`: raw measurement flips +- `affected_detectors`: syndrome flips +- `affected_observables`: measurement-defined logical or experiment outputs +- `affected_tracked_paulis`: tracked Paulis flipped by anticommutation + +## Recommended Surface-Code Memory Path + +For standard surface-code memory experiments: + +1. Build the patch and circuit with `SurfacePatch` and the surface circuit + builders. +2. Generate the circuit-level DEM with the surface decoder helpers. +3. Sample and decode with the native DEM sampler or a matching decoder backend. +4. Use the fault catalog when you need per-location fault anatomy, targeted + lookup decoding, or probability-weighted explanations for a syndrome. + +Use the lower-level `TickCircuit`, `DagCircuit`, `DemBuilder`, and fault-catalog +APIs when you are developing new circuit families, new analysis tools, or new +decoder integrations. diff --git a/docs/user-guide/python-pauli-qec.md b/docs/user-guide/python-pauli-qec.md index b0c72ecfd..9d3e2a27c 100644 --- a/docs/user-guide/python-pauli-qec.md +++ b/docs/user-guide/python-pauli-qec.md @@ -52,16 +52,32 @@ assert Pauli.X.to_int() == 1 ### Pauli Strings `PauliString` represents a multi-qubit Pauli operator with a phase from {+1, -1, +i, -i}. +For inline code, prefer constructor syntax such as `X(0) & Z(1)`. Use sparse +strings like `"X0 Z1"` when text input is useful and dense strings like `"XZ"` +for compact table-like input. ```python -from pecos_rslib import Pauli, PauliString +from pecos_rslib import Pauli, PauliString, X, Z -# From string notation -p = PauliString.from_str("XZI") # X on qubit 0, Z on qubit 1, I on qubit 2 -q = PauliString.from_str("ZXI") +# Constructor syntax +p = X(0) & Z(1) +q = Z(0) & X(1) # From list of (Pauli, qubit) pairs -p = PauliString([(Pauli.X, 0), (Pauli.Z, 1)]) +same_p = PauliString([(Pauli.X, 0), (Pauli.Z, 1)]) +assert p == same_p + +# From string notation +sparse_text = PauliString.from_sparse_str("X0 Z1") +dense_text = PauliString.from_dense_str("XZ") +auto_detected = PauliString.from_str("X0 Z1") +assert p == sparse_text +assert p == dense_text +assert p == auto_detected + +# To string notation +assert p.to_sparse_str() == "+X0 Z1" +assert p.to_dense_str() == "+XZ" # Get components print(p.get_paulis()) # [(Pauli.X, 0), (Pauli.Z, 1)] @@ -74,9 +90,9 @@ print(p) # Shows sparse representation with non-identity operators ### Matrix Representation ```python -from pecos_rslib import PauliString +from pecos_rslib import X, Z -p = PauliString.from_str("XZ") +p = X(0) & Z(1) matrix = p.to_matrix() # Returns complex matrix as list of lists # Each element is a (real, imag) tuple ``` @@ -86,11 +102,11 @@ matrix = p.to_matrix() # Returns complex matrix as list of lists `PauliStabilizerGroup` represents a group of mutually commuting Pauli strings with real phases (+1 or -1). This is the standard stabilizer group used in QEC. ```python -from pecos_rslib import PauliString, PauliStabilizerGroup +from pecos_rslib import PauliStabilizerGroup, Z # Create from generators -g1 = PauliString.from_str("ZZI") -g2 = PauliString.from_str("IZZ") +g1 = Z(0) & Z(1) +g2 = Z(1) & Z(2) group = PauliStabilizerGroup([g1, g2]) # Basic properties @@ -100,7 +116,7 @@ print(group.num_generators()) # 2 print(group.is_independent()) # True # Membership testing (GF(2) span) -ziz = PauliString.from_str("ZIZ") +ziz = Z(0) & Z(2) print(group.contains(ziz)) # True (ZIZ = ZZI * IZZ) print(group.contains_with_phase(ziz)) # True (with correct +1 phase) @@ -124,12 +140,12 @@ group = PauliStabilizerGroup.from_str("Z0 Z1\nZ1 Z2") ### Modifying Groups ```python -from pecos_rslib import PauliString, PauliStabilizerGroup +from pecos_rslib import PauliStabilizerGroup, X group = PauliStabilizerGroup.from_str("ZZI\nIZZ") # Add a generator (must commute with existing generators) -group.add_generator(PauliString.from_str("XXX")) +group.add_generator(X(0) & X(1) & X(2)) # Remove a generator by index removed = group.remove_generator(2) # Returns the removed PauliString @@ -144,10 +160,10 @@ group.merge(other) `PauliSequence` is an ordered list of Pauli strings with no constraints (they can anticommute). Provides GF(2) symplectic analysis. ```python -from pecos_rslib import PauliString, PauliSequence +from pecos_rslib import PauliSequence, X, Y, Z -p1 = PauliString.from_str("XZ") -p2 = PauliString.from_str("ZX") +p1 = X(0) & Z(1) +p2 = Z(0) & X(1) seq = PauliSequence([p1, p2]) # Analysis @@ -156,10 +172,10 @@ print(seq.is_abelian()) # False (XZ and ZX anticommute) # Commutation matrix comm = seq.commutation_matrix() -# comm[i][j] is True if seq[i] commutes with seq[j] +# comm[i][j] is 1 if seq[i] anticommutes with seq[j] # GF(2) membership -print(seq.contains(PauliString.from_str("YY"))) # True (XZ * ZX = -YY in GF(2) span) +print(seq.contains(Y(0) & Y(1))) # True (XZ * ZX = -YY in GF(2) span) # Row reduction to independent subset reduced = seq.row_reduce() @@ -237,22 +253,22 @@ for op in logicals: ### Syndrome Computation ```python -from pecos_rslib import StabilizerCode, PauliString +from pecos_rslib import StabilizerCode, X, Z code = StabilizerCode.repetition(3) # X error on qubit 0 -error = PauliString.from_str("XII") +error = X(0) syndrome = code.syndrome(error) print(syndrome) # [True, False] -- triggers first stabilizer # X error on qubit 1 -error = PauliString.from_str("IXI") +error = X(1) syndrome = code.syndrome(error) print(syndrome) # [True, True] -- triggers both stabilizers # Z error (undetectable by Z-stabilizers) -error = PauliString.from_str("ZII") +error = Z(0) syndrome = code.syndrome(error) print(syndrome) # [False, False] ``` diff --git a/docs/user-guide/qec-guppy.md b/docs/user-guide/qec-guppy.md index 078f35d25..5318a6afc 100644 --- a/docs/user-guide/qec-guppy.md +++ b/docs/user-guide/qec-guppy.md @@ -205,6 +205,62 @@ prog = make_color_transversal_cnot_d3(num_rounds=1) prog = make_surface_transversal_cnot(distance=5, num_rounds=2) ``` +## Writing Your Own QEC Circuit + +You can write QEC circuits directly in Guppy without using the factory functions. Here is a minimal 3-qubit repetition code: + +```python +from guppylang import guppy +from guppylang.std.builtins import array +from guppylang.std.quantum import qubit, cx, measure, measure_array + + +@guppy.struct +class RepSyndrome: + """Two-bit syndrome for the 3-qubit repetition code.""" + + s: array[bool, 2] + + +@guppy +def extract_rep_syndrome(data: array[qubit, 3]) -> RepSyndrome: + """Measure Z_0 Z_1 and Z_1 Z_2 stabilizers.""" + a0 = qubit() + a1 = qubit() + + # Z_0 Z_1 stabilizer + cx(data[0], a0) + cx(data[1], a0) + + # Z_1 Z_2 stabilizer + cx(data[1], a1) + cx(data[2], a1) + + s0 = measure(a0) + s1 = measure(a1) + + return RepSyndrome(array(s0, s1)) + + +@guppy +def rep_code_experiment() -> tuple[array[bool, 3], RepSyndrome]: + """Run one round of the 3-qubit repetition code.""" + data = array(qubit(), qubit(), qubit()) + syndrome = extract_rep_syndrome(data) + results = measure_array(data) + return results, syndrome + + +# Verify it compiles +compiled = rep_code_experiment.compile() +``` + +Key patterns: +- `@guppy.struct` defines data types (qubits are linear — they must be consumed) +- `@guppy` functions can call each other freely +- Ancilla qubits are allocated with `qubit()` and consumed by `measure()` +- Use `measure_array()` to measure all qubits in an array at once + ## Generated Code Structure ```hidden-python @@ -256,28 +312,20 @@ def measure_z_stab_0(az: qubit, data: array[qubit, 9]) -> bool: ### Syndrome Extraction - +The generated module includes a `syndrome_extraction` function that applies all stabilizer measurements in a parallelized CNOT schedule and returns the syndrome: + ```python -@guppy -def syndrome_extraction( - surf: SurfaceCode_3x3, - ax: qubit, - az: qubit, -) -> Syndrome_3x3: - """Extract full syndrome.""" - # Z stabilizers - sz0 = measure_z_stab_0(az, surf.data) - sz1 = measure_z_stab_1(az, surf.data) - # ... - - # X stabilizers - sx0 = measure_x_stab_0(ax, surf.data) - sx1 = measure_x_stab_1(ax, surf.data) - # ... - - return Syndrome_3x3(array(sx0, sx1, ...), array(sz0, sz1, ...)) +from pecos.guppy import generate_surface_code_module + +source = generate_surface_code_module(d=3) + +# The generated module contains the full syndrome extraction circuit +assert "def syndrome_extraction" in source +assert "Syndrome_3x3" in source ``` +To see the full generated code, see [Viewing Generated Source](#viewing-generated-source) below. + ## Viewing Generated Source To see the generated Guppy source code: diff --git a/docs/user-guide/quantum-info.md b/docs/user-guide/quantum-info.md new file mode 100644 index 000000000..7c70530d4 --- /dev/null +++ b/docs/user-guide/quantum-info.md @@ -0,0 +1,280 @@ +# Quantum Information Primitives + +PECOS exposes Rust-backed channel representations and measure functions for +workflows that connect process characterization to QEC simulation. These APIs +live at `pecos.quantum_info` in Python and in the `pecos-quantum` crate in Rust. + +Use these types when you need exact channel data, validation, or process +metrics before reducing a model to Pauli rates, a detector error model, or a +fault catalog. + +## Channel Representations + +PECOS currently provides seven concrete channel representations: + +| Type | Purpose | +| ---- | ------- | +| `PauliChannel` | Sparse Pauli error probabilities. | +| `Ptm` | Dense Pauli transfer matrix. | +| `KrausOps` | Kraus-operator representation. | +| `ChoiMatrix` | Choi-matrix representation for channel validation and tomography. | +| `SuperOp` | Dense column-stacked superoperator. | +| `ChiMatrix` | Process matrix in the Pauli basis. | +| `Stinespring` | Stinespring isometry. | + +Use the representation that matches the question you are asking: + +| Goal | Start with | +| ---- | ---------- | +| Small pure-state examples | State vectors | +| Noisy states and entanglement measures | Density matrices | +| Operational noise construction | `KrausOps` | +| Complete positivity, trace preservation, and tomography reconstruction | `ChoiMatrix` | +| Pauli-basis channel diagnostics | `Ptm` | +| Sparse stochastic Pauli noise | `PauliChannel` | +| Environment/isometry models | `Stinespring` | + +PECOS uses these conventions consistently: + +| Convention | Meaning | +| ---------- | ------- | +| Qubit order | Little-endian: qubit 0 is the least-significant computational-basis bit. | +| Dense Pauli labels | Highest-numbered qubit first, so `IX` on two qubits means I on qubit 1 and X on qubit 0. | +| Sparse Pauli strings | Constructor and sparse text forms use explicit qubit IDs, e.g. `X(0) & Z(3)` or `"X0 Z3"`. | +| PTM basis order | Dense Pauli labels in PECOS basis-label order. | +| Superoperator order | Column-stacked operator vectorization. | +| Choi matrix | Built from PECOS's column-stacked superoperator convention. A trace-preserving channel has output partial trace equal to identity. | +| Subsystem order | Subsystem 0 is the fastest-varying tensor factor. Qubit helpers follow the same little-endian rule. | + +```python +from pecos.quantum_info import PauliChannel, process_fidelity + +channel = PauliChannel.one_qubit(px=0.001, py=0.0005, pz=0.002) +print(channel.probabilities()) +print(channel.total_error_rate()) + +ptm = channel.to_ptm() +identity = type(ptm).identity(1) +print(process_fidelity(ptm, identity)) +``` + +For Pauli channels, PECOS also provides exact dependency-free diamond norm and +diamond distance helpers: + +```python +from pecos.quantum_info import ( + PauliChannel, + pauli_channel_diamond_distance, + pauli_channel_diamond_norm, +) + +left = PauliChannel.one_qubit(0.001, 0.0, 0.0) +right = PauliChannel.one_qubit(0.0, 0.0, 0.001) + +print(pauli_channel_diamond_norm(left, right)) +print(pauli_channel_diamond_distance(left, right)) +``` + +Arbitrary-channel diamond norm is intentionally not exposed yet. General +channels require a semidefinite program, and PECOS will only expose that API +once the solver and SDP assembly live in Rust with PECOS-owned validation. The +current Rust groundwork covers the exact Pauli-channel formula plus internal +linear-algebra helpers for future SDP assembly. + +For multi-qubit Pauli channels, pass a label-to-probability map: + +```python +from pecos.quantum_info import PauliChannel + +from_labels = PauliChannel.from_probabilities( + 2, + { + "II": 0.98, + "IX": 0.01, + "ZI": 0.01, + }, +) +``` + +You can also use `PauliString` keys when the channel is written in PECOS's typed +Pauli style: + +```python +from pecos.quantum import X, Z +from pecos_rslib import PauliString +from pecos.quantum_info import PauliChannel + +from_paulis = PauliChannel.from_probabilities( + 2, + { + PauliString.I(): 0.98, + X(0): 0.01, + Z(1): 0.01, + }, +) + +assert from_paulis.probabilities() == { + "II": 0.98, + "IX": 0.01, + "ZI": 0.01, +} +``` + +## Choi Validation + +`ChoiMatrix` exposes checks that are useful when importing reconstructed +processes from tomography or generated channels from another model: + +```python +from pecos.quantum_info import Ptm + +choi = Ptm.identity(1).to_choi() +assert choi.is_completely_positive() +assert choi.is_trace_preserving() +assert choi.is_cptp() +assert choi.is_unital() + +trace_check = choi.partial_trace_output() +``` + +For PECOS's Choi convention, a trace-preserving channel satisfies +`partial_trace_output() == I`. + +## Process Tomography Helpers + +`ProcessTomographyDesign.matrix_unit(n)` gives the complete computational +matrix-unit operator basis used by PECOS Choi reconstruction. This is a +linear-inversion design for exact channel characterization and simulator +validation; it is not a physical state-preparation recipe. + +```python +from pecos.quantum_info import ProcessTomographyDesign, Ptm + +design = ProcessTomographyDesign.matrix_unit(1) +assert design.input_metadata_all() == [ + (0, 0, 0), # |0><0| + (1, 1, 0), # |1><0| + (2, 0, 1), # |0><1| + (3, 1, 1), # |1><1| +] + +choi = Ptm.identity(1).to_choi() +outputs = design.simulate_outputs(choi) +reconstructed = design.reconstruct_choi(outputs) +assert reconstructed.matrix() == choi.matrix() +``` + +Physical process tomography is a separate layer. A physical workflow prepares +experimentally realizable input states, measures in chosen bases, aggregates +shot counts, and reconstructs an estimated channel before converting it into +`Ptm`, `ChoiMatrix`, or another channel representation. The current +`ProcessTomographyDesign` is the Rust-backed exact reconstruction primitive +that those higher-level experiment-design helpers should build on. + +The dense channel forms convert through the same Rust-backed validation path: + +```python +from pecos.quantum_info import Ptm + +ptm = Ptm.identity(1) +superop = ptm.to_superop() +chi = ptm.to_chi() +stinespring = ptm.to_kraus().to_stinespring() + +assert superop.to_ptm().matrix() == ptm.matrix() +assert chi.to_ptm().matrix() == ptm.matrix() +assert stinespring.to_kraus().is_trace_preserving() +``` + +## State and Process Measures + +State measures accept Python lists of complex values: + +```python +from pecos.quantum_info import ( + entropy, + hellinger_distance, + negativity, + partial_trace_qubits, + partial_trace_subsystems, + purity, + schmidt_decomposition, + shannon_entropy, + state_fidelity, +) + +zero = [1.0 + 0.0j, 0.0 + 0.0j] +plus = [2.0**-0.5, 2.0**-0.5] + +assert state_fidelity(zero, zero) == 1.0 +assert abs(state_fidelity(zero, plus) - 0.5) < 1e-12 + +rho_zero = [[1.0 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.0 + 0.0j]] +assert purity(rho_zero) == 1.0 +assert entropy(rho_zero) == 0.0 + +bell = [2.0**-0.5 + 0.0j, 0.0j, 0.0j, 2.0**-0.5 + 0.0j] +bell_rho = [ + [0.5 + 0.0j, 0.0j, 0.0j, 0.5 + 0.0j], + [0.0j, 0.0j, 0.0j, 0.0j], + [0.0j, 0.0j, 0.0j, 0.0j], + [0.5 + 0.0j, 0.0j, 0.0j, 0.5 + 0.0j], +] + +assert abs(negativity(bell_rho, [2, 2], 1) - 0.5) < 1e-12 +assert len(schmidt_decomposition(bell, [2, 2], [0])) == 2 +assert partial_trace_qubits(bell_rho, 2, [1]) == [ + [0.5 + 0.0j, 0.0 + 0.0j], + [0.0 + 0.0j, 0.5 + 0.0j], +] +assert partial_trace_subsystems(bell_rho, [2, 2], [1]) == [ + [0.5 + 0.0j, 0.0 + 0.0j], + [0.0 + 0.0j, 0.5 + 0.0j], +] + +assert shannon_entropy([0.5, 0.5], 2.0) == 1.0 +assert hellinger_distance([1.0, 0.0], [0.0, 1.0]) == 1.0 +``` + +Process measures operate on `Ptm` values: + +```python +from pecos.quantum_info import Ptm, average_gate_fidelity, gate_error + +ideal = Ptm.identity(1) +actual = Ptm.identity(1) + +assert average_gate_fidelity(actual, ideal) == 1.0 +assert gate_error(actual, ideal) == 0.0 +``` + +## Random Generators + +Seeded random generators are available for tests and examples: + +```python +from pecos.quantum_info import random_density_matrix, random_quantum_channel + +rho = random_density_matrix(num_qubits=1, seed=123) +channel = random_quantum_channel(num_qubits=1, num_kraus=2, seed=123) +assert channel.is_trace_preserving() +``` + +`random_density_matrix` samples Hilbert-Schmidt random density matrices. +`random_quantum_channel` samples CPTP channels through a random Stinespring +isometry. + +## Relationship to QEC APIs + +These channel and measure APIs are exact quantum-information tools. They do not +replace detector error models, fault catalogs, or decoders. A typical workflow +is: + +1. Characterize or construct a channel as `KrausOps`, `ChoiMatrix`, `Ptm`, or + `PauliChannel`. +2. Validate the channel and compute state or process measures. +3. Reduce the channel to the noise model needed by a QEC simulation. +4. Build a DEM or fault catalog and estimate logical error rate with a decoder. + +This separation keeps exact channel analysis distinct from the compressed fault +models used for large-scale QEC studies. diff --git a/docs/user-guide/quantum-operator-algebra.md b/docs/user-guide/quantum-operator-algebra.md index 92e3c598d..8e6b9025d 100644 --- a/docs/user-guide/quantum-operator-algebra.md +++ b/docs/user-guide/quantum-operator-algebra.md @@ -15,7 +15,7 @@ This guide covers PECOS's quantum operator type system: from single-qubit Paulis PECOS organizes quantum operators into a strict hierarchy where each level is a subset of the next: ```text -Pauli ⊂ Clifford ⊂ Unitary ⊂ Channel +Pauli ⊂ Clifford ⊂ Unitary ⊂ Gate ⊂ Channel ``` Each level has its own representation optimized for what it can express: @@ -25,9 +25,10 @@ Each level has its own representation optimized for what it can express: | Pauli | `PauliString` | Tensor products of I, X, Y, Z with phase | Exact commutation, symplectic algebra | | Clifford | `CliffordRep` | Gates that map Paulis to Paulis | Fast conjugation via Heisenberg picture | | Unitary | `UnitaryRep` | Any unitary (including non-Clifford) | Lazy expression tree with algebraic ops | -| Channel | `ChannelExpr` | Non-unitary operations (measurement, noise) | Compose and tensor arbitrary operations | +| Gate | `GateExpr` | Ideal circuit operations (unitary, preparation, measurement, reset) | Compose and tensor circuit operations | +| Channel | `ChannelExpr` | General CPTP maps and noise/decoherence operations | Compose and tensor arbitrary physical maps | -The unified `Op` type wraps all four and automatically promotes when you combine operators from different levels. +The unified `Op` type wraps all five and automatically promotes when you combine operators from different levels. --- @@ -38,7 +39,7 @@ The unified `Op` type wraps all four and automatically promotes when you combine The primary Pauli type. Stores a sparse list of non-identity single-qubit Paulis with a global phase from `{+1, -1, +i, -i}`. ```rust -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; use pecos_core::PauliOperator; // Single-qubit constructors @@ -64,7 +65,7 @@ let neg = -X(0); // -X ```rust use pecos_core::pauli::algebra::i; -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; let imag = i * X(0); // iX let neg_imag = -i * Y(1); // -iY @@ -73,7 +74,7 @@ let neg_imag = -i * Y(1); // -iY ### Key Operations ```rust -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; use pecos_core::PauliOperator; let a = X(0) & Z(1); @@ -108,12 +109,23 @@ All three implement the `PauliOperator` trait, which provides `multiply()`, `wei ### Parsing from Strings +Use constructors for ordinary code. Use sparse strings when explicit qubit +indices make text input clearer, and dense strings when positional notation is +useful for compact tables. + ```rust use pecos_core::PauliString; -let p: PauliString = "XZI".parse().unwrap(); // X(0) & Z(1) -let q: PauliString = "+XZZXI".parse().unwrap(); // with explicit phase -let r: PauliString = "-iYX".parse().unwrap(); // -i * Y(0) * X(1) +let sparse: PauliString = "X0 Z3".parse().unwrap(); +let dense: PauliString = "XIIZ".parse().unwrap(); +let explicit_sparse = PauliString::from_sparse_str("X0 Z3").unwrap(); +let explicit_dense = PauliString::from_dense_str("XIIZ").unwrap(); + +assert_eq!(sparse, dense); +assert_eq!(sparse, explicit_sparse); +assert_eq!(dense, explicit_dense); +assert_eq!(sparse.to_sparse_str(), "+X0 Z3"); +assert_eq!(sparse.to_dense_str(None), "+XIIZ"); ``` --- @@ -126,7 +138,7 @@ Represents a Clifford gate via the Heisenberg picture: how it transforms each Pa ```rust use pecos_core::clifford_rep::CliffordRep; -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; // Hadamard on qubit 0: X -> Z, Z -> X let h = CliffordRep::h(0); @@ -155,7 +167,7 @@ The 24 single-qubit Clifford gates and 14 two-qubit entangling gates are also av A lazy expression tree that can represent any quantum unitary, including non-Clifford gates (T, arbitrary rotations). Supports composition, tensor products, and adjoint algebraically. ```rust -use pecos_core::unitary_rep::*; +use pecos_core::unitary::*; use pecos_core::Angle64; // Named gates @@ -183,19 +195,33 @@ let h_all = Hs([0, 1, 2]); // H on three qubits --- -## Level 4: Channels (Non-Unitary Operations) +## Level 4: Gates (Ideal Circuit Operations) -### ChannelExpr +### GateExpr -Represents non-unitary quantum operations: measurements, preparations, noise channels. +Represents ideal circuit operations: unitaries, measurements, preparations, resets, and their compositions. ```rust use pecos_core::op::*; -// Measurement and preparation +// Measurement, preparation, and reset let mz = MZ(0); // Z-basis measurement on qubit 0 let mx = MX(1); // X-basis measurement on qubit 1 let pz = PZ(0); // Prepare |0> on qubit 0 +let reset = Reset(0); // Reset to |0> +assert!(mz.is_gate()); +``` + +--- + +## Level 5: Channels (Physical Maps) + +### ChannelExpr + +Represents general CPTP maps, which PECOS usually uses for noise and decoherence. + +```rust +use pecos_core::op::*; // Noise channels let depol = Depolarizing(0.01, 0); // 1% depolarizing on qubit 0 @@ -203,18 +229,18 @@ let deph = Dephasing(0.02, 1); // 2% dephasing on qubit 1 let amp_damp = AmplitudeDamping(0.05, 0); // T1 decay let phase_damp = PhaseDamping(0.03, 0); // T2 dephasing let erasure = Erasure(0.01, 0); // Erasure channel -let reset = Reset(0); // Reset to |0> let leak = Leakage(0.001, 0); // Leakage to non-computational state // Custom Pauli channel let pauli_ch = PauliChannel(0.01, 0.01, 0.01, 0); // px, py, pz +assert!(depol.is_channel()); ``` --- ## The Unified `Op` Type -`Op` wraps all four levels and automatically promotes when you combine operators: +`Op` wraps all five levels and automatically promotes when you combine operators: ```rust use pecos_core::op::*; @@ -231,8 +257,12 @@ assert!(c.is_clifford()); let u = X(0) & H(3) & T(5); assert!(u.is_unitary()); -// Adding a measurement promotes to Channel -let ch = H(0) & MZ(1); +// Adding a measurement promotes to Gate +let g = H(0) & MZ(1); +assert!(g.is_gate()); + +// Adding noise promotes to Channel +let ch = g & Depolarizing(0.01, 2); assert!(ch.is_channel()); ``` @@ -273,8 +303,8 @@ assert!(m.try_dg().is_none()); use pecos_core::op::*; let circuit = CX(0, 3) & H(5); -assert_eq!(circuit.num_qubits(), 6); // spans qubits 0..5 -assert_eq!(circuit.qubits(), vec![0, 1, 2, 3, 4, 5]); // full range +assert_eq!(circuit.num_qubits(), 6); // matrix span is qubits 0..5 +assert_eq!(circuit.qubits(), vec![0, 3, 5]); // actual support ``` --- @@ -299,7 +329,7 @@ An ordered list of Pauli strings with GF(2) symplectic analysis. No commutativit ```rust use pecos_quantum::PauliSequence; -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; let seq = PauliSequence::new(vec![ Zs([0, 1]), @@ -328,7 +358,7 @@ A set of distinct Pauli strings. Two strings are equal only if they have the sam ```rust use pecos_quantum::PauliSet; -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; let mut set = PauliSet::new(); set.insert(&X(0)); @@ -350,7 +380,7 @@ A commuting subgroup of the Pauli group. Generators may carry any `QuarterPhase` ```rust use pecos_quantum::PauliGroup; -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; use pecos_core::pauli::algebra::i; // Generators with imaginary phase @@ -373,7 +403,7 @@ The standard stabilizer group for QEC. All generators must commute and have phas ```rust use pecos_quantum::PauliStabilizerGroup; -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; // Repetition code stabilizers let stab = PauliStabilizerGroup::new(vec![ @@ -401,7 +431,7 @@ let transformed = stab.apply_clifford(&h); ```rust use pecos_quantum::{PauliSequence, PauliGroup, PauliStabilizerGroup, PauliSet}; -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; // Upward (widening) -- always succeeds let stab = PauliStabilizerGroup::new(vec![Zs([0, 1])]).unwrap(); @@ -429,7 +459,7 @@ The collection types feed into the QEC types in `pecos-qec`: ```rust use pecos_quantum::PauliStabilizerGroup; use pecos_qec::StabilizerCode; -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; // Build a stabilizer group let group = PauliStabilizerGroup::new(vec![ diff --git a/docs/user-guide/simulators.md b/docs/user-guide/simulators.md index 5c0f29104..397a497d7 100644 --- a/docs/user-guide/simulators.md +++ b/docs/user-guide/simulators.md @@ -82,10 +82,14 @@ fn main() -> Result<(), Box> { | **StateVec** | State vector | Arbitrary circuits, small systems | None | | **StabVec** | Clifford + Rz | Clifford circuits with Z rotations | None | | **PauliProp** | Fault tracking | Error propagation analysis | None | -| **CuStateVec** | State vector (GPU) | Large circuits with GPU | CUDA, cuQuantum | +| **CuStateVec** | State vector (GPU, Python) | Large circuits with GPU | CUDA, cuQuantum | +| **CudaStateVec** | State vector (GPU, Rust) | Large circuits, reproducible seeded runs | CUDA, cuQuantum, cuda-rust build | +| **CudaStabilizer** | Stabilizer (GPU, Rust) | Very large Clifford circuits (1000s of qubits) | CUDA, cuQuantum, cuda-rust build | | **MPS** | Tensor network | Low-entanglement circuits | CUDA, cuQuantum | | **density_matrix** | Density matrix | Noisy/mixed state simulation | None | +Two additional specialized backends—**SparseStabPy** (pure-Python reference implementation for debugging) and **CoinToss** (random measurement outcomes for testing)—are documented below but rarely used in production. + ## Choosing a Simulator ``` @@ -110,24 +114,6 @@ fn main() -> Result<(), Box> { └── Need mixed states? ──→ density_matrix ``` -## Setup - -The examples below use this Bell state circuit: - -```python -from pecos import sim, Qasm - -circuit = """ -OPENQASM 2.0; -include "qelib1.inc"; -qreg q[2]; -creg c[2]; -h q[0]; -cx q[0], q[1]; -measure q -> c; -""" -``` - ## Stabilizer Simulators Stabilizer simulators efficiently simulate **Clifford circuits** (H, S, CNOT, CZ, and similar gates). They scale polynomially with qubit count, making them ideal for quantum error correction. @@ -395,6 +381,8 @@ Approximate performance characteristics (relative, not absolute): | StateVec | ★★★ | ★★★ | 2^n | ~25-30 | | StabVec | ★★★★ | Limited to Clifford + Rz | Low | 1000+ | | CuStateVec | ★★★★ | ★★★★★ | 2^n (GPU) | ~30-35 | +| CudaStateVec | ★★★★ | ★★★★★ | 2^n (GPU) | ~30-35 | +| CudaStabilizer | ★★★★★ | N/A | Low | 1000+ (GPU) | | MPS | ★★★ | ★★★ | ~n × chi² | Varies | | density_matrix | ★★ | ★★ | 4^n | ~15 | diff --git a/docs/user-guide/stabilizer-codes.md b/docs/user-guide/stabilizer-codes.md index fff2e13cd..a9907169c 100644 --- a/docs/user-guide/stabilizer-codes.md +++ b/docs/user-guide/stabilizer-codes.md @@ -12,7 +12,7 @@ This guide covers working with Pauli strings and stabilizer codes in PECOS's Rus - Converting between code types ```hidden-rust -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; use pecos_core::PauliOperator; fn main() { @@ -27,7 +27,7 @@ Pauli strings are the fundamental building block. PECOS provides a concise const === ":fontawesome-brands-rust: Rust" ```rust - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; // Single-qubit Paulis let x0 = X(0); // X on qubit 0 @@ -51,11 +51,11 @@ Pauli strings are the fundamental building block. PECOS provides a concise const === ":fontawesome-brands-python: Python" ```python - from pecos.quantum import PauliString + from pecos.quantum import X, Z - # From string notation - p = PauliString.from_str("XZI") - q = PauliString.from_str("ZXI") + # Constructor notation + p = X(0) & Z(1) + q = Z(0) & X(1) # Commutation check print(p.commutes_with(q)) # False (anticommute) @@ -69,7 +69,7 @@ A `StabilizerCode` is defined by its stabilizer generators and a qubit count: ```rust use pecos_qec::StabilizerCode; - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; // 3-qubit bit-flip repetition code: generators ZZI, IZZ let group = pecos_quantum::PauliStabilizerGroup::new(vec![ @@ -165,7 +165,7 @@ Check which stabilizers an error anticommutes with: ```rust use pecos_qec::StabilizerCode; -use pecos_core::pauli::constructors::*; +use pecos_core::pauli::*; let code = StabilizerCode::repetition(3); @@ -208,7 +208,7 @@ When stabilizer generators don't touch all qubits, the explicit `num_qubits` mat ```rust use pecos_qec::StabilizerCode; use pecos_quantum::PauliStabilizerGroup; -use pecos_core::pauli::constructors::Zs; +use pecos_core::pauli::Zs; // ZZ on qubits 0,1 -- but we declare 4 physical qubits let group = PauliStabilizerGroup::new(vec![ @@ -227,7 +227,7 @@ For fault tolerance analysis, use `StabilizerCodeSpec`. This stores explicit pai ```rust use pecos_qec::StabilizerCodeSpec; -use pecos_core::{Xs, Zs}; +use pecos_core::pauli::{Xs, Zs}; // Build a 3-qubit bit-flip code with explicit logicals let code = StabilizerCodeSpec::builder(3) @@ -269,7 +269,7 @@ spec.verify().unwrap(); ```rust use pecos_qec::{StabilizerCodeSpec, StabilizerFlipChecker}; -use pecos_core::{Xs, Zs}; +use pecos_core::pauli::{Xs, Zs}; let code = StabilizerCodeSpec::builder(7) .check(Xs([0, 2, 4, 6])) @@ -298,4 +298,4 @@ PECOS separates stabilizer code concerns into layers: - **`StabilizerCode`** (pecos-qec): Mathematical code definition, on-demand analysis. - **`StabilizerCodeSpec`** (pecos-qec): Operational specification with verification and fault tolerance integration. -For architecture details, see [Stabilizer Code Architecture](../development/STABILIZER_CODE_ARCHITECTURE.md). +For architecture details, see `design/STABILIZER_CODE_ARCHITECTURE.md` in the repository root. diff --git a/examples/surface/README.md b/examples/surface/README.md index 1319554f2..d7bc1afba 100644 --- a/examples/surface/README.md +++ b/examples/surface/README.md @@ -6,6 +6,22 @@ This directory contains the current rotated-surface-code memory sweep tooling: logical error rate, and writes plots plus optional JSON/HTML reports. - `surface_sweep_report.py`: rebuilds an HTML dashboard from already-generated sweep artifacts without rerunning simulations. +- `d3_fault_catalog_lookup.rs`: Rust example that builds a distance-3 + surface-code memory experiment, enumerates low-weight fault configurations + with the fault catalog API, and aggregates a truncated lookup decoder table. + +## Rust Fault-Catalog Lookup Example + +Run the d=3 lookup-table example from the PECOS repo root: + +```bash +cargo run -p pecos-qec --example surface_d3_fault_catalog_lookup +``` + +The expensive loop stays in Rust: for `k = 0..=2`, the example lazily walks +`catalog.fault_configurations(k)`, XORs detector/tracked-Pauli effects via the +catalog iterator, and sums `configuration_probability` into a +`syndrome -> logical -> probability` table. ## Typical Workflow diff --git a/examples/surface/analyze_data.py b/examples/surface/analyze_data.py new file mode 100644 index 000000000..93dbf66e1 --- /dev/null +++ b/examples/surface/analyze_data.py @@ -0,0 +1,588 @@ +r"""Analyze decoder performance data from JSON shards. + +Reads one or more JSON shards produced by ``generate_data.py`` and +computes: + - Decoder comparison tables (LER + timing at each operating point) + - Threshold curves (LER vs p for each decoder, grouped by distance) + - Per-round fitting (when multiple duration multipliers are present) + +Writes an analysis JSON that ``build_report.py`` consumes. + +Example: + uv run python examples/surface/analyze_data.py results/data.json + uv run python examples/surface/analyze_data.py shard1.json shard2.json -o analysis/ +""" + +from __future__ import annotations + +import argparse +import json +import math +from dataclasses import asdict, dataclass, field +from pathlib import Path + +# -- Analysis data model ------------------------------------------------------ + + +@dataclass +class ComparisonRow: + """One decoder's stats at one operating point.""" + + decoder: str + distance: int + basis: str + physical_error_rate: float + num_rounds: int + num_shots: int + num_errors: int + logical_error_rate: float + ci_low: float + ci_high: float + per_shot_mean: float + per_shot_median: float + per_shot_p99: float + per_shot_max: float + quantiles: list[float] = field(default_factory=list) + + +@dataclass +class ComparisonTable: + """All decoder rows for one (distance, p, rounds) point, sorted by LER.""" + + distance: int + basis: str + physical_error_rate: float + num_rounds: int + num_shots: int + rows: list[ComparisonRow] + + +@dataclass +class ThresholdCurvePoint: + """One (p, LER) point on a threshold curve.""" + + physical_error_rate: float + # LER over d rounds (either projected from fit, or raw at r=2d) + logical_error_rate: float + ci_low: float + ci_high: float + num_shots: int + num_errors: int + # Per-round fitted epsilon (when multiple round counts available) + fitted_epsilon: float | None = None + fitted_epsilon_ci_low: float | None = None + fitted_epsilon_ci_high: float | None = None + num_round_points: int = 1 + # Per-round LER (= fitted_epsilon when fitted, else raw_LER / num_rounds) + per_round_ler: float | None = None + per_round_ci_low: float | None = None + per_round_ci_high: float | None = None + + +@dataclass +class ThresholdCurve: + """LER vs p for one (decoder, distance, basis) combination. + + When multiple round counts are available, the threshold points use + fitted per-round epsilon from p_L(r) = 0.5*(1-(1-2*eps)^r). + """ + + decoder: str + distance: int + basis: str + points: list[ThresholdCurvePoint] + uses_fitted_epsilon: bool = False + + +@dataclass +class DurationCurvePoint: + """One (rounds, LER) point on a duration curve.""" + + num_rounds: int + logical_error_rate: float + ci_low: float + ci_high: float + num_shots: int + num_errors: int + + +@dataclass +class DurationCurve: + """LER vs rounds for one (decoder, distance, basis, p) combination.""" + + decoder: str + distance: int + basis: str + physical_error_rate: float + points: list[DurationCurvePoint] + + +@dataclass +class ThresholdEstimate: + """Estimated threshold from curve crossing for one decoder. + + metric: "per_round" or "d_round" indicating which LER was used for the crossing. + """ + + decoder: str + basis: str + estimated_p_th: float + d_small: int + d_large: int + metric: str = "per_round" # "per_round", "d_round", or "fss" + std_error: float | None = None + + +@dataclass +class AnalysisResult: + """Full analysis output.""" + + config: dict + comparison_tables: list[ComparisonTable] = field(default_factory=list) + threshold_curves: list[ThresholdCurve] = field(default_factory=list) + duration_curves: list[DurationCurve] = field(default_factory=list) + threshold_estimates: list[ThresholdEstimate] = field(default_factory=list) + + +# -- Wilson score interval ---------------------------------------------------- + + +def _wilson_ci(n: int, k: int, z: float = 1.96) -> tuple[float, float]: + """95% Wilson score confidence interval for binomial proportion.""" + if n == 0: + return 0.0, 0.0 + p = k / n + if p == 0: + return 0.0, 1 - (1 - 0.95) ** (1 / n) # Clopper-Pearson upper for 0 successes + if p == 1: + return (1 - 0.95) ** (1 / n), 1.0 + denom = 1 + z * z / n + centre = (p + z * z / (2 * n)) / denom + half = z * math.sqrt(p * (1 - p) / n + z * z / (4 * n * n)) / denom + return max(0.0, centre - half), min(1.0, centre + half) + + +# -- Per-round epsilon fitting ------------------------------------------------ + + +def _ler_model(epsilon: float, r: int) -> float: + """p_L(r) = 0.5 * (1 - (1 - 2*epsilon)^r).""" + if epsilon <= 0 or epsilon >= 0.5: + return 0.5 + return 0.5 * (1.0 - (1.0 - 2.0 * epsilon) ** r) + + +def _fit_epsilon(round_values: list[int], ler_values: list[float]) -> float | None: + """Fit per-round epsilon from (rounds, LER) pairs using golden section search. + + Minimises sum of squared residuals of p_L(r) = 0.5*(1-(1-2*eps)^r). + Returns None if fitting is not possible. + """ + if not round_values or all(v == 0 for v in ler_values): + return None + + def cost(eps: float) -> float: + return sum((ler - _ler_model(eps, r)) ** 2 for r, ler in zip(round_values, ler_values, strict=True)) + + # Golden section search on [1e-8, 0.499] + a, b = 1e-8, 0.499 + gr = (math.sqrt(5) + 1) / 2 + for _ in range(80): + c = b - (b - a) / gr + d = a + (b - a) / gr + if cost(c) < cost(d): + b = d + else: + a = c + return (a + b) / 2 + + +# -- Load and merge shards ---------------------------------------------------- + + +def _load_shards(paths: list[Path]) -> tuple[dict, list[dict]]: + """Load JSON shards and return (merged_config, all_points).""" + all_points = [] + config = {} + for path in paths: + data = json.loads(path.read_text()) + if not config: + config = dict(data.get("config", {})) + all_points.extend(data.get("points", [])) + + # Derive config fields from actual data (shards may cover different subsets) + all_decoders = set() + all_distances = set() + all_error_rates = set() + total_shots = 0 + for pt in all_points: + all_distances.add(pt["distance"]) + all_error_rates.add(pt["physical_error_rate"]) + total_shots = max(total_shots, pt.get("num_shots", 0)) + for ds in pt.get("decoder_stats", []): + all_decoders.add(ds["decoder"]) + config["decoders"] = sorted(all_decoders) + config["distances"] = sorted(all_distances) + config["error_rates"] = sorted(all_error_rates) + if total_shots: + config["shots"] = total_shots + return config, all_points + + +# -- Analysis ----------------------------------------------------------------- + + +def analyze(config: dict, points: list[dict]) -> AnalysisResult: + """Compute comparison tables and threshold curves from raw data points.""" + result = AnalysisResult(config=config) + + # Group points by (distance, basis, p, rounds) + from collections import defaultdict + + by_cell: dict[tuple, list[dict]] = defaultdict(list) + for pt in points: + key = (pt["distance"], pt["basis"], pt["physical_error_rate"], pt["num_rounds"]) + by_cell[key].append(pt) + + # Comparison tables: one per (distance, p, rounds) cell + for (d, basis, p, r), cell_points in sorted(by_cell.items()): + # Merge decoder stats across shards for same cell + decoder_stats: dict[str, dict] = {} + total_shots = 0 + for pt in cell_points: + total_shots += pt["num_shots"] + for ds in pt.get("decoder_stats", []): + name = ds["decoder"] + if name not in decoder_stats: + decoder_stats[name] = { + "num_errors": 0, + "num_shots": 0, + "per_shot_mean": ds["per_shot_mean"], + "per_shot_median": ds["per_shot_median"], + "per_shot_p99": ds["per_shot_p99"], + "per_shot_max": ds["per_shot_max"], + "quantiles": ds.get("quantiles", []), + } + decoder_stats[name]["num_errors"] += ds["num_errors"] + decoder_stats[name]["num_shots"] += pt["num_shots"] + + rows = [] + for dec_name, stats in decoder_stats.items(): + n = stats["num_shots"] + k = stats["num_errors"] + ler = k / n if n > 0 else 0.0 + ci_lo, ci_hi = _wilson_ci(n, k) + rows.append( + ComparisonRow( + decoder=dec_name, + distance=d, + basis=basis, + physical_error_rate=p, + num_rounds=r, + num_shots=n, + num_errors=k, + logical_error_rate=ler, + ci_low=ci_lo, + ci_high=ci_hi, + per_shot_mean=stats["per_shot_mean"], + per_shot_median=stats["per_shot_median"], + per_shot_p99=stats["per_shot_p99"], + per_shot_max=stats["per_shot_max"], + quantiles=stats.get("quantiles", []), + ), + ) + + rows.sort(key=lambda r: r.logical_error_rate) + + result.comparison_tables.append( + ComparisonTable( + distance=d, + basis=basis, + physical_error_rate=p, + num_rounds=r, + num_shots=total_shots, + rows=rows, + ), + ) + + # Threshold curves: group by (decoder, distance, basis) across all rounds. + # For each p, if multiple round counts exist, fit per-round epsilon. + # Otherwise, use the raw LER at the single available round count. + tc_groups: dict[tuple, dict[float, list[ComparisonRow]]] = defaultdict(lambda: defaultdict(list)) + for table in result.comparison_tables: + for row in table.rows: + key = (row.decoder, row.distance, row.basis) + tc_groups[key][row.physical_error_rate].append(row) + + for (dec, d, basis), p_to_rows in sorted(tc_groups.items()): + curve_points = [] + has_fitting = False + for p in sorted(p_to_rows): + rows = p_to_rows[p] + if len(rows) >= 2: + # Multiple round counts: fit per-round epsilon + round_vals = [r.num_rounds for r in rows] + ler_vals = [r.logical_error_rate for r in rows] + eps = _fit_epsilon(round_vals, ler_vals) + if eps is not None: + has_fitting = True + # Project to d-round LER for display + projected_ler = _ler_model(eps, d) + # CI from fitting upper/lower LER bounds + ci_lo_vals = [r.ci_low for r in rows] + ci_hi_vals = [r.ci_high for r in rows] + eps_lo = _fit_epsilon(round_vals, ci_hi_vals) # higher LER -> higher eps + eps_hi = _fit_epsilon(round_vals, ci_lo_vals) # lower LER -> lower eps + total_shots = sum(r.num_shots for r in rows) + total_errors = sum(r.num_errors for r in rows) + proj_ci_lo = _ler_model(eps_hi, d) if eps_hi else projected_ler + proj_ci_hi = _ler_model(eps_lo, d) if eps_lo else projected_ler + curve_points.append( + ThresholdCurvePoint( + physical_error_rate=p, + logical_error_rate=projected_ler, + ci_low=proj_ci_lo, + ci_high=proj_ci_hi, + num_shots=total_shots, + num_errors=total_errors, + fitted_epsilon=eps, + fitted_epsilon_ci_low=eps_hi, + fitted_epsilon_ci_high=eps_lo, + num_round_points=len(rows), + per_round_ler=eps, + per_round_ci_low=eps_hi, + per_round_ci_high=eps_lo, + ), + ) + continue + + # Single round count: use raw LER, estimate per-round from + # p_L(r) = 0.5*(1-(1-2*eps)^r) inverted. + row = rows[0] + raw_per_round = _fit_epsilon([row.num_rounds], [row.logical_error_rate]) + raw_pr_lo = _fit_epsilon([row.num_rounds], [row.ci_high]) + raw_pr_hi = _fit_epsilon([row.num_rounds], [row.ci_low]) + curve_points.append( + ThresholdCurvePoint( + physical_error_rate=row.physical_error_rate, + logical_error_rate=row.logical_error_rate, + ci_low=row.ci_low, + ci_high=row.ci_high, + num_shots=row.num_shots, + num_errors=row.num_errors, + per_round_ler=raw_per_round, + per_round_ci_low=raw_pr_hi, + per_round_ci_high=raw_pr_lo, + ), + ) + + result.threshold_curves.append( + ThresholdCurve( + decoder=dec, + distance=d, + basis=basis, + points=curve_points, + uses_fitted_epsilon=has_fitting, + ), + ) + + # Duration curves: LER vs rounds for each (decoder, distance, basis, p). + # Only meaningful when multiple round counts exist per (d, p). + dur_groups: dict[tuple, list[ComparisonRow]] = defaultdict(list) + for table in result.comparison_tables: + for row in table.rows: + key = (row.decoder, row.distance, row.basis, row.physical_error_rate) + dur_groups[key].append(row) + + for (dec, d, basis, p), rows in sorted(dur_groups.items()): + if len(rows) < 2: + continue # need at least 2 round counts + dur_points = [ + DurationCurvePoint( + num_rounds=row.num_rounds, + logical_error_rate=row.logical_error_rate, + ci_low=row.ci_low, + ci_high=row.ci_high, + num_shots=row.num_shots, + num_errors=row.num_errors, + ) + for row in sorted(rows, key=lambda r: r.num_rounds) + ] + result.duration_curves.append( + DurationCurve( + decoder=dec, + distance=d, + basis=basis, + physical_error_rate=p, + points=dur_points, + ), + ) + + # Threshold estimates: find where smallest/largest distance curves cross. + # Compute for both per-round LER and d-round LER. + import itertools as _itertools + + # Threshold estimates via FSS fit (Wang-Harrington-Preskill form). + # Uses ALL (p, d, per_round_ler) points across ALL distances simultaneously. + # Falls back to pairwise crossing only as a seed for the FSS fit. + est_groups: dict[tuple, list[ThresholdCurve]] = defaultdict(list) + for curve in result.threshold_curves: + est_groups[(curve.decoder, curve.basis)].append(curve) + + def _crude_crossing_seed(dec_curves: list[ThresholdCurve]) -> float | None: + """Quick pairwise crossing of smallest/largest distance for FSS seed.""" + distances = sorted({c.distance for c in dec_curves}) + if len(distances) < 2: + return None + small = next(c for c in dec_curves if c.distance == distances[0]) + large = next(c for c in dec_curves if c.distance == distances[-1]) + small_by_p = { + pt.physical_error_rate: (pt.per_round_ler or pt.logical_error_rate) + for pt in small.points + if (pt.per_round_ler or pt.logical_error_rate) and (pt.per_round_ler or pt.logical_error_rate) > 0 + } + large_by_p = { + pt.physical_error_rate: (pt.per_round_ler or pt.logical_error_rate) + for pt in large.points + if (pt.per_round_ler or pt.logical_error_rate) and (pt.per_round_ler or pt.logical_error_rate) > 0 + } + shared_ps = sorted(set(small_by_p) & set(large_by_p)) + diffs = [(p, large_by_p[p] - small_by_p[p]) for p in shared_ps] + for (p0, diff0), (p1, diff1) in _itertools.pairwise(diffs): + if diff0 == 0.0: + return p0 + if diff0 * diff1 < 0.0: + t = abs(diff0) / (abs(diff0) + abs(diff1)) + return math.exp((1.0 - t) * math.log(p0) + t * math.log(p1)) + return None + + for (dec, basis), dec_curves in sorted(est_groups.items()): + distances = sorted({c.distance for c in dec_curves}) + if len(distances) < 2: + continue + + # FSS fit for per-round LER + plist_pr, dlist_pr, plog_pr = [], [], [] + for curve in dec_curves: + for pt in curve.points: + pr = pt.per_round_ler + if pr and pr > 0: + plist_pr.append(pt.physical_error_rate) + dlist_pr.append(curve.distance) + plog_pr.append(pr) + + # FSS fit for d-round LER + plist_dr, dlist_dr, plog_dr = [], [], [] + for curve in dec_curves: + for pt in curve.points: + lr = pt.logical_error_rate + if lr and lr > 0: + plist_dr.append(pt.physical_error_rate) + dlist_dr.append(curve.distance) + plog_dr.append(lr) + + # Crude seed for FSS optimizer + seed = _crude_crossing_seed(dec_curves) + + for metric, plist, dlist, plog in [ + ("fss_per_round", plist_pr, dlist_pr, plog_pr), + ("fss_d_round", plist_dr, dlist_dr, plog_dr), + ]: + if len(plist) < 5 or len(set(dlist)) < 2: + continue + s = seed if seed is not None else sorted(plist)[len(plist) // 2] + fss = _fit_fss_threshold(plist, dlist, plog, seed_threshold=s) + if fss is not None: + result.threshold_estimates.append( + ThresholdEstimate( + decoder=dec, + basis=basis, + estimated_p_th=fss[0], + d_small=min(distances), + d_large=max(distances), + metric=metric, + std_error=fss[1], + ), + ) + + return result + + +def _fit_fss_threshold( + plist: list[float], + dlist: list[int], + plog: list[float], + *, + seed_threshold: float, + seed_nu: float = 1.0, + window_factor_low: float = 0.4, + window_factor_high: float = 2.0, +) -> tuple[float, float] | None: + """Fit the Wang-Harrington-Preskill FSS form using pecos.analysis.threshold_curve. + + Returns (p_th, p_th_std_error) or None if the fit fails. + """ + try: + from pecos.analysis.threshold_curve import func as fss_func + from pecos.analysis.threshold_curve import threshold_fit + except ImportError: + return None + + # Filter to a window around the seed threshold + low = seed_threshold * window_factor_low + high = seed_threshold * window_factor_high + indices = [i for i, p in enumerate(plist) if low <= p <= high and plog[i] > 0] + if len(indices) < 5 or len({dlist[i] for i in indices}) < 2: + return None + + import pecos as pc + + p_arr = pc.array([plist[i] for i in indices]) + d_arr = pc.array([float(dlist[i]) for i in indices]) + ler_arr = pc.array([plog[i] for i in indices]) + mean_ler = float(sum(plog[i] for i in indices) / len(indices)) + initial = [seed_threshold, seed_nu, mean_ler, 1.0, 1.0] + + try: + popt, stdev = threshold_fit(p_arr, d_arr, ler_arr, fss_func, initial) + except Exception: + return None + + p_th = float(popt[0]) + p_th_se = float(stdev[0]) + if p_th <= 0: + return None + return (p_th, p_th_se) + + +# -- CLI ---------------------------------------------------------------------- + + +def main() -> int: + """CLI entry point for data analysis.""" + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("shards", nargs="+", type=Path, help="JSON shard file(s) from generate_data.py") + parser.add_argument("-o", "--output-dir", type=str, default=None) + args = parser.parse_args() + + config, points = _load_shards(args.shards) + print(f"Loaded {len(points)} data points from {len(args.shards)} shard(s)") + + result = analyze(config, points) + print(f" {len(result.comparison_tables)} comparison tables") + print(f" {len(result.threshold_curves)} threshold curves") + + out = Path(args.output_dir) if args.output_dir else args.shards[0].parent + out.mkdir(parents=True, exist_ok=True) + json_path = out / "analysis.json" + json_path.write_text(json.dumps(asdict(result), indent=2)) + print(f"Wrote {json_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/surface/brickwork_sweep.py b/examples/surface/brickwork_sweep.py new file mode 100644 index 000000000..88fe9f01e --- /dev/null +++ b/examples/surface/brickwork_sweep.py @@ -0,0 +1,800 @@ +r"""Brickwork circuit sweep: transversal gate performance across widths, depths, distances. + +Generates mirrored brickwork circuits (H + CX) at various configurations, +decodes with multiple decoders, and writes JSON results that can be fed +to ``build_report.py`` for HTML/PDF reports. + +The mirrored brickwork guarantees output = |0...0>, so logical errors +are unambiguous. + +Decoder names: + observable_subgraph:INNER -- OSD 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) + +Example: + 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 \ + --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 \ + --shots 2000 --save-html --open +""" + +from __future__ import annotations + +import argparse +import json +import random +import sys +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python" / "quantum-pecos" / "src")) + + +# -- Data model --------------------------------------------------------------- + + +@dataclass +class DecoderResult: + decoder: str + num_errors: int + logical_error_rate: float + decode_seconds: float + + +@dataclass +class BrickworkPoint: + distance: int + width: int + depth: int + physical_error_rate: float + num_shots: int + seed: int + sample_seconds: float + decoder_results: list[DecoderResult] = field(default_factory=list) + + +@dataclass +class BrickworkShard: + config: dict + points: list[BrickworkPoint] = field(default_factory=list) + total_seconds: float = 0.0 + + +# -- Circuit builder ----------------------------------------------------------- + + +def build_mirrored_brickwork(width, depth, seed, patch, rounds=2): + """Build a mirrored brickwork circuit (identity, output = |0...0>).""" + from pecos.qec.surface import LogicalCircuitBuilder + + nq = patch.geometry.num_data + patch.geometry.num_ancilla + rng = random.Random(seed) + + b = LogicalCircuitBuilder() + labels = [f"Q{i}" for i in range(width)] + for i, label in enumerate(labels): + b.add_patch(patch, label, qubit_offset=i * nq) + + eff = dict.fromkeys(labels, "Z") + b.add_memory(labels, rounds, "Z") + + ops_forward = [] + for layer in range(depth): + layer_ops = [] + for label in labels: + if rng.random() < 0.5: + b.add_transversal_h(label) + eff[label] = "X" if eff[label] == "Z" else "Z" + layer_ops.append(("H", label)) + b.add_memory(labels, rounds, basis={label: eff[label] for label in labels}) + + offset = layer % 2 + cx_applied = [] + for i in range(offset, width - 1, 2): + ctrl, tgt = labels[i], labels[i + 1] + if eff[ctrl] == eff[tgt]: + b.add_transversal_cx(ctrl, tgt) + cx_applied.append((ctrl, tgt)) + if cx_applied: + b.add_memory(labels, rounds, basis={label: eff[label] for label in labels}) + layer_ops.append(("CX", cx_applied)) + ops_forward.append(layer_ops) + + # Mirror + for layer_ops in reversed(ops_forward): + for op_type, *args in reversed(layer_ops): + if op_type == "CX": + for ctrl, tgt in reversed(args[0]): + if eff[ctrl] == eff[tgt]: + b.add_transversal_cx(ctrl, tgt) + b.add_memory(labels, rounds, basis={label: eff[label] for label in labels}) + for op_type, *args in reversed(layer_ops): + if op_type == "H": + label = args[0] + b.add_transversal_h(label) + eff[label] = "X" if eff[label] == "Z" else "Z" + b.add_memory(labels, rounds, basis={label: eff[label] for label in labels}) + + return b + + +def build_t_injection_circuit(distance, _seed, patch, rounds_per_layer): + """Build a T-gate injection circuit: memory + T injection + memory.""" + from pecos.qec.surface import LogicalCircuitBuilder + + nq = patch.geometry.num_data + patch.geometry.num_ancilla + b = LogicalCircuitBuilder() + b.add_patch(patch, "D", qubit_offset=0) + b.add_patch(patch, "A", qubit_offset=nq) + + # Memory → T injection → Memory + rounds = max(rounds_per_layer, distance) + b.add_memory(["D", "A"], rounds=rounds, basis="Z") + b.add_t_via_injection("D", "A", rounds_before=rounds, rounds_after=rounds) + return b + + +# -- Sweep -------------------------------------------------------------------- + + +def run_sweep( + *, + distances: list[int], + widths: list[int], + depths: list[int], + error_rates: list[float], + decoders: list[str], + shots: int, + circuit_seed: int, + rounds_per_layer: int, +) -> BrickworkShard: + """Run the full brickwork sweep.""" + from pecos.qec.surface import SurfacePatch + from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + + config = { + "distances": distances, + "widths": widths, + "depths": depths, + "error_rates": error_rates, + "decoders": decoders, + "shots": shots, + "circuit_seed": circuit_seed, + "rounds_per_layer": rounds_per_layer, + } + + shard = BrickworkShard(config=config) + t_total = time.perf_counter() + + total_cells = len(distances) * len(widths) * len(depths) * len(error_rates) + cell_idx = 0 + + for d in distances: + patch = SurfacePatch.create(distance=d) + for w in widths: + for depth in depths: + # Build circuit once per (d, w, depth) — reuse across error rates + b = build_mirrored_brickwork(w, depth, circuit_seed, patch, rounds_per_layer) + sc = b.stab_coords() + + for p in error_rates: + cell_idx += 1 + print(f"[{cell_idx}/{total_cells}] d={d} w={w} depth={depth} p={p:.4g} ...", end=" ", flush=True) + + dem_str = b.build_dem(p1=p, p2=p, p_meas=p, p_prep=p) + + # Sample + t0 = time.perf_counter() + parsed = ParsedDem.from_string(dem_str) + rust_sampler = parsed.to_dem_sampler() + batch = rust_sampler.generate_samples(shots, seed=circuit_seed + cell_idx) + sample_sec = time.perf_counter() - t0 + + point = BrickworkPoint( + distance=d, + width=w, + depth=depth, + physical_error_rate=p, + num_shots=shots, + seed=circuit_seed, + sample_seconds=sample_sec, + ) + + # Decode with each decoder + for decoder_name in decoders: + t0 = time.perf_counter() + if decoder_name.startswith("logical_circuit"): + from pecos_rslib.qec import LogicalCircuitDecoder + + # Format: logical_circuit:budget:inner + parts = decoder_name.split(":") + budget = parts[1] if len(parts) > 1 else "offline" + inner = parts[2] if len(parts) > 2 else "pymatching" + desc = b.build_algorithm_descriptor(p1=p, p2=p, p_meas=p, p_prep=p) + dec = LogicalCircuitDecoder(desc, budget, inner) + errors = dec.decode_count(batch) + elif decoder_name.startswith("logical_algorithm"): + from pecos_rslib.qec import LogicalAlgorithmDecoder + + parts = decoder_name.split(":", 1) + inner = parts[1] if len(parts) > 1 else "pymatching" + 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"): + parts = decoder_name.split(":", 1) + inner = parts[1] if len(parts) > 1 else "pymatching" + osd = ObservableSubgraphDecoder(dem_str, sc, inner) + # Use parallel decode for large shot counts + if shots >= 5000: + errors = osd.decode_count_parallel(batch, dem_str, sc, inner) + else: + errors = osd.decode_count(batch) + else: + msg = ( + f"Unknown decoder: '{decoder_name}'. " + f"Supported: observable_subgraph:INNER, " + f"logical_circuit:BUDGET:INNER, " + f"logical_algorithm:INNER" + ) + raise ValueError(msg) + dec_sec = time.perf_counter() - t0 + ler = errors / shots + + point.decoder_results.append( + DecoderResult( + decoder=decoder_name, + num_errors=errors, + logical_error_rate=ler, + decode_seconds=dec_sec, + ), + ) + + shard.points.append(point) + lers = {r.decoder: f"{r.logical_error_rate:.5f}" for r in point.decoder_results} + print(f"LER={lers}") + + shard.total_seconds = time.perf_counter() - t_total + return shard + + +# -- HTML report --------------------------------------------------------------- + + +def write_html_report(shard: BrickworkShard, path: Path, coherent_results=None) -> None: + """Write an HTML report from the sweep results.""" + from collections import defaultdict + + # Group by (width, depth) for each distance + tables = defaultdict(list) + for pt in shard.points: + tables[(pt.distance, pt.physical_error_rate)].append(pt) + + style = """ + :root { + color-scheme: light dark; + --bg: #f8fafc; --fg: #0f172a; + --hero-bg: linear-gradient(135deg, #e0f2fe, #f8fafc 55%, #dcfce7); + --card-bg: white; --card-border: #dbeafe; --card-shadow: rgba(15,23,42,0.05); + --meta-bg: rgba(255,255,255,0.82); + --muted: #475569; --link: #2563eb; + --table-stripe: #f1f5f9; --table-border: #e2e8f0; + --good: #16a34a; --warn: #ea580c; --bad: #dc2626; + } + @media (prefers-color-scheme: dark) { + :root { + --bg: #0f172a; --fg: #e2e8f0; + --hero-bg: linear-gradient(135deg, #1e293b, #0f172a 55%, #1a2e1a); + --card-bg: #1e293b; --card-border: #334155; --card-shadow: rgba(0,0,0,0.3); + --meta-bg: rgba(30,41,59,0.82); + --muted: #94a3b8; --link: #60a5fa; + --table-stripe: #0f172a; --table-border: #334155; + --good: #4ade80; --warn: #fb923c; --bad: #f87171; + } + } + body { + margin: 0; + font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg); color: var(--fg); + } + main { max-width: 1100px; margin: 0 auto; padding: 32px 24px 56px; } + h1, h2, h3, p { margin-top: 0; } + .hero { + background: var(--hero-bg); + border: 1px solid var(--card-border); + border-radius: 20px; + padding: 28px 32px; + margin-bottom: 24px; + } + .hero h1 { font-size: 1.6rem; margin-bottom: 0.3em; } + .hero p { color: var(--muted); margin: 0; } + .meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; + margin-top: 18px; + } + .meta-card { + background: var(--meta-bg); + border: 1px solid var(--card-border); + border-radius: 14px; + padding: 14px 16px; + } + .meta-card strong { + display: block; font-size: 0.78rem; text-transform: uppercase; + letter-spacing: 0.04em; color: var(--muted); margin-bottom: 4px; + } + .section { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 18px; + padding: 22px 26px; + margin-top: 20px; + box-shadow: 0 10px 24px var(--card-shadow); + overflow-x: auto; + } + .section h2 { font-size: 1.15rem; margin-bottom: 0.3em; } + .section h3 { font-size: 0.95rem; color: var(--muted); } + .section p { font-size: 0.9rem; color: var(--muted); } + table { border-collapse: collapse; width: 100%; margin: 12px 0 0; } + th, td { padding: 10px 14px; text-align: right; border-bottom: 1px solid var(--table-border); } + th { + font-size: 0.78rem; text-transform: uppercase; + letter-spacing: 0.03em; color: var(--muted); border-bottom-width: 2px; + } + td:first-child, th:first-child { text-align: left; } + tr:nth-child(even) td { background: var(--table-stripe); } + .good { color: var(--good); font-weight: 600; } + .warn { color: var(--warn); font-weight: 600; } + .bad { color: var(--bad); font-weight: 600; } + code { + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 0.85em; background: var(--table-stripe); + padding: 0.15em 0.4em; border-radius: 3px; + } + details.info { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 18px; + margin-top: 20px; + box-shadow: 0 10px 24px var(--card-shadow); + } + details.info > summary { + cursor: pointer; font-size: 0.95rem; font-weight: 600; + padding: 16px 26px; list-style: none; + } + details.info > summary::before { content: "\\25B6\\00a0\\00a0"; font-size: 0.75em; } + details.info[open] > summary::before { content: "\\25BC\\00a0\\00a0"; } + details.info .content { + padding: 0 26px 22px; line-height: 1.7; font-size: 0.9rem; + } + details.info .content h4 { margin: 1em 0 0.3em; } + """ + + # Build meta cards + distances = sorted({pt.distance for pt in shard.points}) if shard.points else [] + decoders_used = sorted({r.decoder for pt in shard.points for r in pt.decoder_results}) + + meta_cards = [] + if distances: + meta_cards.append( + f'

Distances{", ".join(str(d) for d in distances)}
', + ) + if decoders_used: + meta_cards.append(f'
Decoders{len(decoders_used)}
') + if shard.points: + meta_cards.append(f'
Shots{shard.points[0].num_shots:,}
') + meta_cards.append(f'
Time{shard.total_seconds:.1f}s
') + + html_parts = [ + "", + '', + '', + '', + "PECOS Surface Code Report", + f"", + "
", + '
', + "

PECOS Surface Code Report

", + "

Mirrored brickwork circuits, T-gate injection, and coherent noise analysis

", + f'
{" ".join(meta_cards)}
', + "
", + "", + '
Decoding Concepts: Throughput, Reaction Time, and Budgets', + '
', + "

Throughput (avoiding backlog)

", + "

The decoder must process syndrome data at least as fast as the hardware " + "generates it. If syndrome extraction takes Tcycle per round, the " + "decoder must process each round in ≤ Tcycle on average. If it " + "falls behind, a backlog accumulates, causing exponential slowdown of " + "the quantum computation.

", + "", + "

Reaction time

", + "

At feed-forward decision points (T-gate injection, magic state consumption), " + "the physical hardware waits for the decoder to produce a correction. The " + "reaction time is the time available between the last syndrome arriving " + "and the correction being applied. For Clifford-only circuits " + "(like these brickwork circuits), there are no mid-circuit decisions — the " + "Pauli frame is metadata applied at the final measurement. The reaction time is " + "effectively unlimited.

", + "", + "

Budget strategies

", + "

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

", + "
    ", + "
  • unlimited — Full-circuit OSD. Maximum accuracy. " + "Appropriate for Clifford circuits or offline analysis.
  • ", + "
  • windowed / 10ms — Windowed OSD with " + "overlap buffers inside each per-observable subgraph. Bounded latency, full " + "accuracy with sufficient overlap.
  • ", + "
  • 100us / 1us — Tight budget. Windowed " + "without overlap. Accuracy degrades; requires advanced techniques (ghost " + "protocol) for improvement.
  • ", + "
", + "", + "

Compute hardware

", + "

The budget is a constraint, not a measurement. A decode that takes " + "300μs on a CPU might take 30μs on an FPGA. The strategy doesn't change " + "— only whether it fits within the budget on a given compute platform. " + "Profiling on the target hardware determines feasibility.

", + "", + "

References

", + "
    ", + "
  • Riverlane + Rigetti (arXiv:2410.05202): 9.6μs response time, backlog definition
  • ", + "
  • Cain et al. (arXiv:2505.13587): software commitment, <100μs at d=25
  • ", + "
  • Turner et al. (arXiv:2505.23567): ghost protocol for scalable windowed decoding
  • ", + "
  • Serra-Peralta et al. (arXiv:2505.13599): per-observable subgraph MWPM
  • ", + "
", + "
", + "", + ] + + # Separate brickwork and T-injection points + brickwork_tables = defaultdict(list) + t_injection_tables = defaultdict(list) + for pt in shard.points: + if pt.depth == 0: # T-injection marker + t_injection_tables[(pt.distance, pt.physical_error_rate)].append(pt) + else: + brickwork_tables[(pt.distance, pt.physical_error_rate)].append(pt) + + if brickwork_tables: + html_parts.append('
') + html_parts.append("

Brickwork Circuits (Clifford)

") + html_parts.append( + "

Mirrored random gate sequences (identity operation). " + "LER from stochastic depolarizing noise only.

", + ) + + for (d, p), points in sorted(brickwork_tables.items()): + html_parts.append(f"

d={d}, p={p}

") + + decoders = sorted({r.decoder for pt in points for r in pt.decoder_results}) + html_parts.append("") + html_parts.extend(f"" for dec in decoders) + html_parts.append("") + + for pt in sorted(points, key=lambda x: (x.width, x.depth)): + html_parts.append(f"") + for dec in decoders: + r = next((r for r in pt.decoder_results if r.decoder == dec), None) + if r: + cls = "good" if r.logical_error_rate < 0.01 else "warn" if r.logical_error_rate < 0.05 else "bad" + html_parts.append( + f'', + ) + else: + html_parts.append("") + html_parts.append("") + html_parts.append("
WidthDepth{dec}
{pt.width}{pt.depth}{r.logical_error_rate:.5f} ({r.decode_seconds:.2f}s)-
") + + if brickwork_tables: + html_parts.append("
") + + if t_injection_tables: + html_parts.append('
') + html_parts.append("

T-Gate Injection (Non-Clifford)

") + html_parts.append( + "

T gate via magic state teleportation: " + "|T⟩ ancilla + CX + measure + conditional S. " + "Feed-forward decision point for the decoder.

", + ) + + for (d, p), points in sorted(t_injection_tables.items()): + decoders = sorted({r.decoder for pt in points for r in pt.decoder_results}) + html_parts.append(f"

d={d}, p={p}

") + html_parts.append("") + html_parts.extend(f"" for dec in decoders) + html_parts.append("") + + for pt in points: + html_parts.append("") + for dec in decoders: + r = next((r for r in pt.decoder_results if r.decoder == dec), None) + if r: + cls = "good" if r.logical_error_rate < 0.01 else "warn" if r.logical_error_rate < 0.05 else "bad" + html_parts.append( + f'', + ) + else: + html_parts.append("") + html_parts.append("") + html_parts.append("
Circuit{dec}
T-injection{r.logical_error_rate:.5f} ({r.decode_seconds:.2f}s)-
") + + if t_injection_tables: + html_parts.append("
") + + # Coherent noise section + if coherent_results: + html_parts.append('
') + html_parts.append("

Coherent Idle Noise (X-basis Memory)

") + html_parts.append( + "

RZ(θ) rotation on both qubits after each CX gate models " + "uncompensated phase accumulation during idle time. Unlike stochastic " + "Z errors, coherent rotations accumulate constructively — the LER " + "far exceeds the Pauli-twirled equivalent sin²(θ/2). " + "Decoder uses a stochastic-only DEM. Simulated with StateVec.

", + ) + + for (d, p), result in sorted(coherent_results.items()): + html_parts.append(f"

d={d}, p_depol={p}

") + html_parts.append( + "" + "" + "" + "" + "" + "" + "" + "", + ) + for pt in result.points: + cls = "good" if pt.ler < 0.02 else "warn" if pt.ler < 0.05 else "bad" + amplification = ( + f"{pt.ler / result.points[0].ler:.1f}x" if result.points[0].ler > 0 and pt.p_idle > 0 else "" + ) + html_parts.append( + f'' + f"" + f'' + f"" + f"" + f"", + ) + html_parts.append("
θ (rad)sin²(θ/2)LER± SEErrorsvs baseline
{pt.p_idle:.3f}{pt.p_twirled:.5f}{pt.ler:.4f}{pt.standard_error:.4f}{pt.errors}/{pt.shots}{amplification}
") + + html_parts.append("
") + + html_parts.append("
") + path.write_text("\n".join(html_parts)) + print(f"Report written to {path}") + + +# -- CLI ----------------------------------------------------------------------- + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--distances", type=int, nargs="+", default=[3, 5]) + parser.add_argument("--widths", type=int, nargs="+", default=[2, 3, 4]) + parser.add_argument("--depths", type=int, nargs="+", default=[1, 2, 3]) + parser.add_argument( + "--scaled-depth", + action="store_true", + 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("--shots", type=int, default=5000) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--rounds-per-layer", type=int, default=2) + parser.add_argument( + "--include-t-injection", + action="store_true", + help="Include T-gate injection circuits (non-Clifford)", + ) + parser.add_argument( + "--t-injection-only", + action="store_true", + help="Only T-gate injection circuits (skip brickwork)", + ) + parser.add_argument( + "--include-coherent-noise", + action="store_true", + help="Include coherent idle noise sweep (RZ after CX)", + ) + parser.add_argument( + "--coherent-p-idle", + type=float, + nargs="+", + default=[0.0, 0.01, 0.03, 0.05, 0.07, 0.1], + help="Coherent idle RZ angles to sweep", + ) + parser.add_argument("--coherent-shots", type=int, default=None, help="Shots for coherent noise (default: --shots)") + parser.add_argument("--output-dir", type=Path, default=Path("/tmp/brickwork_sweep")) + parser.add_argument("--save-json", action="store_true") + parser.add_argument("--save-html", action="store_true") + parser.add_argument("--open", action="store_true") + args = parser.parse_args() + + if args.t_injection_only: + shard = BrickworkShard( + config={ + "distances": args.distances, + "error_rates": args.error_rates, + "decoders": args.decoders, + "shots": args.shots, + "t_injection_only": True, + }, + ) + elif args.scaled_depth: + # Per-distance depth: 2^((d+1)/2) — challenges the decoder proportionally + # d=3→4, d=5→8, d=7→16, d=9→32 + depths = [int(2 ** ((d + 1) / 2)) for d in args.distances] + print(f"Scaled depths: {dict(zip(args.distances, depths, strict=False))}") + + all_points = [] + for d, depth in zip(args.distances, depths, strict=False): + partial = run_sweep( + distances=[d], + widths=args.widths, + depths=[depth], + error_rates=args.error_rates, + decoders=args.decoders, + shots=args.shots, + circuit_seed=args.seed, + rounds_per_layer=args.rounds_per_layer, + ) + all_points.extend(partial.points) + shard = BrickworkShard( + config={ + "distances": args.distances, + "widths": args.widths, + "scaled_depths": dict(zip(args.distances, depths, strict=False)), + "error_rates": args.error_rates, + "decoders": args.decoders, + "shots": args.shots, + }, + points=all_points, + ) + else: + shard = run_sweep( + distances=args.distances, + widths=args.widths, + depths=args.depths, + error_rates=args.error_rates, + decoders=args.decoders, + shots=args.shots, + circuit_seed=args.seed, + rounds_per_layer=args.rounds_per_layer, + ) + + # 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 + + print("\n--- T-Injection Circuits ---") + for d in args.distances: + patch = SurfacePatch.create(distance=d) + for p in args.error_rates: + b = build_t_injection_circuit(d, args.seed, patch, args.rounds_per_layer) + sc = b.stab_coords() + dem_str = b.build_dem(p1=p, p2=p, p_meas=p, p_prep=p) + parsed = ParsedDem.from_string(dem_str) + batch = parsed.to_dem_sampler().generate_samples(args.shots, seed=args.seed) + + point = BrickworkPoint( + distance=d, + width=2, + depth=0, # depth=0 signals T-injection + physical_error_rate=p, + num_shots=args.shots, + seed=args.seed, + sample_seconds=0, + ) + + for decoder_name in args.decoders: + t0 = time.perf_counter() + if decoder_name.startswith("logical_circuit"): + from pecos_rslib.qec import LogicalCircuitDecoder + + parts = decoder_name.split(":") + budget = parts[1] if len(parts) > 1 else "unlimited" + inner = parts[2] if len(parts) > 2 else "pymatching" + desc = b.build_algorithm_descriptor(p1=p, p2=p, p_meas=p, p_prep=p) + dec = LogicalCircuitDecoder(desc, budget, inner) + errors = dec.decode_count(batch) + elif decoder_name.startswith("logical_algorithm"): + from pecos_rslib.qec import LogicalAlgorithmDecoder + + parts = decoder_name.split(":", 1) + inner = parts[1] if len(parts) > 1 else "pymatching" + 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"): + 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) + else: + msg = ( + f"Unknown decoder: '{decoder_name}'. " + f"Supported: observable_subgraph:INNER, " + f"logical_circuit:BUDGET:INNER, " + f"logical_algorithm:INNER" + ) + raise ValueError(msg) + dec_sec = time.perf_counter() - t0 + ler = errors / args.shots + + point.decoder_results.append( + DecoderResult( + decoder=decoder_name, + num_errors=errors, + logical_error_rate=ler, + decode_seconds=dec_sec, + ), + ) + + shard.points.append(point) + lers = {r.decoder: f"{r.logical_error_rate:.5f}" for r in point.decoder_results} + print(f"d={d} T-injection p={p:.4g} ... LER={lers}") + + # Coherent noise sweep + coherent_results = None + if args.include_coherent_noise: + from coherent_noise_sweep import run_sweep as run_coherent_sweep + + coherent_shots = args.coherent_shots or args.shots + # StateVec is limited to d=3 (17 qubits). Skip larger distances. + coherent_distances = [d for d in args.distances if d <= 3] + if not coherent_distances: + print("\n--- Coherent Idle Noise: skipped (StateVec limited to d<=3) ---") + else: + print("\n--- Coherent Idle Noise ---") + coherent_results = {} + for d in coherent_distances: + for p in args.error_rates: + print(f"d={d} p={p:.4g}:") + result = run_coherent_sweep( + distance=d, + rounds=d, + basis="X", + p_depol=p, + p_idle_values=args.coherent_p_idle, + shots=coherent_shots, + seed=args.seed, + backend="statevec", + lazy_measure=True, + max_bond_dim=128, + ) + coherent_results[(d, p)] = result + + args.output_dir.mkdir(parents=True, exist_ok=True) + + if args.save_json: + json_path = args.output_dir / "brickwork_sweep_results.json" + json_path.write_text(json.dumps(asdict(shard), indent=2)) + print(f"JSON written to {json_path}") + + if args.save_html or args.open: + html_path = args.output_dir / "brickwork_sweep_report.html" + write_html_report(shard, html_path, coherent_results=coherent_results) + if args.open: + import webbrowser + + webbrowser.open(str(html_path)) + + +if __name__ == "__main__": + main() diff --git a/examples/surface/build_report.py b/examples/surface/build_report.py new file mode 100644 index 000000000..ea392b7b6 --- /dev/null +++ b/examples/surface/build_report.py @@ -0,0 +1,1588 @@ +r"""Build HTML and/or PDF reports from analysis JSON. + +Reads the analysis JSON produced by ``analyze_data.py`` and renders: + - HTML report with comparison tables + threshold curve plots (inline SVG) + - PDF report with the same content via matplotlib + +Example: + uv run python examples/surface/build_report.py results/analysis.json --html --pdf + uv run python examples/surface/build_report.py results/analysis.json --html --open +""" + +from __future__ import annotations + +import argparse +import html as html_mod +import json +import math +from collections import defaultdict +from pathlib import Path +from textwrap import dedent + +# -- Load analysis ------------------------------------------------------------ + + +def _load_analysis(path: Path) -> dict: + return json.loads(path.read_text()) + + +# -- Threshold curve SVG (inline) --------------------------------------------- + + +def _sci(v: float) -> str: + """Format a value in clean scientific notation for axis labels.""" + if v == 0: + return "0" + return f"{v:.1e}" + + +_COLORS = [ + "#2563eb", # blue + "#dc2626", # red + "#16a34a", # green + "#9333ea", # purple + "#ea580c", # orange + "#0891b2", # cyan + "#be185d", # pink + "#854d0e", # brown +] + +_DASH_PATTERNS = [ + "", # solid + "6,3", # dashed + "2,3", # dotted + "8,3,2,3", # dash-dot +] + + +def _build_threshold_svg( + curves: list[dict], + title: str, + width: int = 560, + height: int = 380, + color_by: str = "decoder", + threshold_p: float | None = None, + y_field: str = "logical_error_rate", + y_label: str = "Logical error rate", + decoder_color_map: dict[str, str] | None = None, + distance_color_map: dict[int, str] | None = None, +) -> str: + """Build an inline SVG plot of LER vs p for multiple (decoder, distance) curves. + + decoder_color_map / distance_color_map: fixed color assignments for + consistency across plots. When provided, overrides index-based coloring. + y_field: which field on each point to use for y-axis values. + Also uses "{y_field}" with "ci_low"/"ci_high" replaced accordingly. + threshold_p: if provided, draw a vertical dashed line at this p value. + color_by: "decoder" assigns colors by decoder (dashes by distance), + "distance" assigns colors by distance (dashes by decoder). + """ + margin = {"top": 45, "right": 160, "bottom": 55, "left": 70} + plot_w = width - margin["left"] - margin["right"] + plot_h = height - margin["top"] - margin["bottom"] + + # Resolve CI field names based on y_field + if y_field == "per_round_ler": + ci_lo_field, ci_hi_field = "per_round_ci_low", "per_round_ci_high" + else: + ci_lo_field, ci_hi_field = "ci_low", "ci_high" + + def _y(pt: dict) -> float: + v = pt.get(y_field) + return v if v is not None and v > 0 else 0.0 + + # Collect all p and y values for axis scaling + all_p = [] + all_ler = [] + for curve in curves: + for pt in curve["points"]: + all_p.append(pt["physical_error_rate"]) + yv = _y(pt) + if yv > 0: + all_ler.append(yv) + + if not all_p or not all_ler: + return f'No data' + + p_vals_pos = [p for p in all_p if p > 0] + p_min = min(p_vals_pos) if p_vals_pos else 1e-4 + p_max = max(p_vals_pos) if p_vals_pos else 1.0 + ler_min = max(1e-5, min(all_ler) * 0.5) + ler_max = min(1.0, max(all_ler) * 2.0) + + # Log scale for both axes + log_p_min = math.log10(p_min * 0.8) + log_p_max = math.log10(p_max * 1.2) + log_ler_min = math.log10(ler_min) + log_ler_max = math.log10(ler_max) + + def x_of(p: float) -> float: + if p <= 0: + return margin["left"] + log_p = math.log10(p) + frac = (log_p - log_p_min) / (log_p_max - log_p_min) if log_p_max != log_p_min else 0.5 + return margin["left"] + frac * plot_w + + def y_of(ler: float) -> float: + if ler <= 0: + return margin["top"] + plot_h + log_val = math.log10(ler) + frac = (log_val - log_ler_min) / (log_ler_max - log_ler_min) if log_ler_max != log_ler_min else 0.5 + return margin["top"] + (1 - frac) * plot_h + + parts = [ + f'', + # Background + f'', + # Title + f'{html_mod.escape(title)}', + ] + + # Grid lines (horizontal, log scale for y) + for exp in range(math.floor(log_ler_min), math.ceil(log_ler_max) + 1): + y = y_of(10**exp) + if margin["top"] <= y <= margin["top"] + plot_h: + parts.append( + f'', + ) + parts.append( + f'1e{exp}', + ) + + # Grid lines (vertical, log scale for x) + for exp in range(math.floor(log_p_min), math.ceil(log_p_max) + 1): + x = x_of(10**exp) + if margin["left"] <= x <= margin["left"] + plot_w: + parts.append( + f'', + ) + + # Axis labels + parts.append( + f'Physical error rate (p)', + ) + parts.append( + f'{html_mod.escape(y_label)}', + ) + + # X-axis tick labels (log-spaced) + # Show ticks at 1, 2, 5 * 10^n (standard log-scale subdivisions) + x_ticks = set() + for exp in range(math.floor(log_p_min) - 1, math.ceil(log_p_max) + 1): + for mult in [1.0, 2.0, 5.0]: + x_ticks.add(mult * 10.0**exp) + # Filter to visible range and limit density + visible_ticks = sorted(p for p in x_ticks if p > 0 and log_p_min <= math.log10(p) <= log_p_max) + # If too many ticks, keep only powers of 10 + if len(visible_ticks) > 8: + visible_ticks = sorted(p for p in visible_ticks if p == 10.0 ** round(math.log10(p))) + for p in visible_ticks: + x = x_of(p) + parts.append( + f'{_sci(p)}', + ) + + # Plot area border + parts.append( + f'', + ) + + # Threshold vertical line + if threshold_p is not None and p_min <= threshold_p <= p_max: + tx = x_of(threshold_p) + parts.append( + f'', + ) + parts.append( + f'p_th~{_sci(threshold_p)}', + ) + + # Draw curves + decoder_names = list(dict.fromkeys(c["decoder"] for c in curves)) + distances = sorted({c["distance"] for c in curves}) + + legend_y = margin["top"] + 10 + for curve in sorted(curves, key=lambda c: (decoder_names.index(c["decoder"]), c["distance"])): + dec_idx = decoder_names.index(curve["decoder"]) + dist_idx = distances.index(curve["distance"]) + if color_by == "distance": + color = (distance_color_map or {}).get(curve["distance"], _COLORS[dist_idx % len(_COLORS)]) + dash = _DASH_PATTERNS[dec_idx % len(_DASH_PATTERNS)] + else: + color = (decoder_color_map or {}).get(curve["decoder"], _COLORS[dec_idx % len(_COLORS)]) + dash = _DASH_PATTERNS[dist_idx % len(_DASH_PATTERNS)] + + # CI shaded band (draw first so it's behind everything) + band_upper = [] + band_lower = [] + for pt in curve["points"]: + yv = _y(pt) + if yv <= 0: + continue + ci_lo = pt.get(ci_lo_field) or 0 + ci_hi = pt.get(ci_hi_field) or 0 + if ci_lo > 0 and ci_hi > 0: + x = x_of(pt["physical_error_rate"]) + band_upper.append((x, y_of(ci_hi))) + band_lower.append((x, y_of(ci_lo))) + + if len(band_upper) >= 2: + band_d = " ".join(f"{'M' if i == 0 else 'L'}{x:.1f},{y:.1f}" for i, (x, y) in enumerate(band_upper)) + for x, y in reversed(band_lower): + band_d += f" L{x:.1f},{y:.1f}" + band_d += " Z" + parts.append( + f'', + ) + + # Center line + pts = [(x_of(pt["physical_error_rate"]), y_of(_y(pt))) for pt in curve["points"] if _y(pt) > 0] + + if len(pts) >= 2: + path_d = " ".join(f"{'M' if i == 0 else 'L'}{x:.1f},{y:.1f}" for i, (x, y) in enumerate(pts)) + dash_attr = f' stroke-dasharray="{dash}"' if dash else "" + parts.append( + f'', + ) + + # Data points with CI whiskers + for pt in curve["points"]: + yv = _y(pt) + if yv <= 0: + continue + x = x_of(pt["physical_error_rate"]) + y = y_of(yv) + parts.append(f'') + # CI whiskers + ci_lo = pt.get(ci_lo_field) or 0 + ci_hi = pt.get(ci_hi_field) or 0 + if ci_lo > 0 and ci_hi > 0: + y_lo = y_of(ci_lo) + y_hi = y_of(ci_hi) + parts.append( + f'', + ) + + # Legend entry + label = f"{curve['decoder']} d={curve['distance']}" + lx = margin["left"] + plot_w + 12 + dash_attr = f' stroke-dasharray="{dash}"' if dash else "" + parts.append( + f'', + ) + parts.append( + f'{html_mod.escape(label)}', + ) + legend_y += 16 + + parts.append("") + return "\n".join(parts) + + +def _build_duration_svg( + curves: list[dict], + title: str, + width: int = 560, + height: int = 380, +) -> str: + """Build an inline SVG of LER vs rounds for multiple (decoder, distance) curves at fixed p.""" + margin = {"top": 45, "right": 160, "bottom": 55, "left": 70} + plot_w = width - margin["left"] - margin["right"] + plot_h = height - margin["top"] - margin["bottom"] + + all_r = [] + all_ler = [] + for curve in curves: + for pt in curve["points"]: + all_r.append(pt["num_rounds"]) + if pt["logical_error_rate"] > 0: + all_ler.append(pt["logical_error_rate"]) + + if not all_r or not all_ler: + return f'No data' + + r_min, r_max = min(all_r), max(all_r) + ler_min = max(1e-5, min(all_ler) * 0.5) + ler_max = min(1.0, max(all_ler) * 2.0) + log_ler_min = math.log10(ler_min) + log_ler_max = math.log10(ler_max) + + def x_of(r: float) -> float: + if r_max == r_min: + return margin["left"] + plot_w / 2 + return margin["left"] + (r - r_min) / (r_max - r_min) * plot_w + + def y_of(ler: float) -> float: + if ler <= 0: + return margin["top"] + plot_h + log_val = math.log10(ler) + frac = (log_val - log_ler_min) / (log_ler_max - log_ler_min) if log_ler_max != log_ler_min else 0.5 + return margin["top"] + (1 - frac) * plot_h + + parts = [ + f'', + f'', + f'{html_mod.escape(title)}', + ] + + # Grid + axis labels + for exp in range(math.floor(log_ler_min), math.ceil(log_ler_max) + 1): + y = y_of(10**exp) + if margin["top"] <= y <= margin["top"] + plot_h: + parts.append( + f'', + ) + parts.append( + f'1e{exp}', + ) + + parts.append( + f'Rounds', + ) + parts.append( + f'Logical error rate', + ) + + for r in sorted(set(all_r)): + x = x_of(r) + parts.append( + f'{r}', + ) + + parts.append( + f'', + ) + + # Draw curves + legend_y = margin["top"] + 10 + for curve_idx, curve in enumerate(curves): + color = _COLORS[curve_idx % len(_COLORS)] + dash = _DASH_PATTERNS[curve_idx // len(_COLORS) % len(_DASH_PATTERNS)] + + pts = [ + (x_of(pt["num_rounds"]), y_of(pt["logical_error_rate"])) + for pt in curve["points"] + if pt["logical_error_rate"] > 0 + ] + + if len(pts) >= 2: + path_d = " ".join(f"{'M' if i == 0 else 'L'}{x:.1f},{y:.1f}" for i, (x, y) in enumerate(pts)) + dash_attr = f' stroke-dasharray="{dash}"' if dash else "" + parts.append( + f'', + ) + + for pt in curve["points"]: + if pt["logical_error_rate"] <= 0: + continue + x = x_of(pt["num_rounds"]) + y = y_of(pt["logical_error_rate"]) + parts.append(f'') + if pt["ci_low"] > 0 and pt["ci_high"] > 0: + y_lo = y_of(pt["ci_low"]) + y_hi = y_of(pt["ci_high"]) + parts.append( + f'', + ) + + label = f"{curve['decoder']} d={curve['distance']}" + lx = margin["left"] + plot_w + 12 + dash_attr = f' stroke-dasharray="{dash}"' if dash else "" + parts.append( + f'', + ) + parts.append( + f'{html_mod.escape(label)}', + ) + legend_y += 16 + + parts.append("") + return "\n".join(parts) + + +def _build_timing_svg( + tables: list[dict], + title: str, + width: int = 700, + height: int = 400, +) -> str: + """Build a violin plot SVG showing decode time distributions per decoder. + + Uses quantile data from DecodeStats (21 percentiles at 0%, 5%, ..., 100%) + to draw symmetric violins on a log-scale horizontal axis. + Falls back to box-style whiskers if quantiles are unavailable. + """ + margin = {"top": 45, "right": 30, "bottom": 55, "left": 120} + plot_w = width - margin["left"] - margin["right"] + plot_h = height - margin["top"] - margin["bottom"] + + # Collect all quantile arrays per decoder (one per operating point). + # We'll merge by taking the element-wise geometric mean. + decoder_quantiles: dict[str, list[list[float]]] = {} + for table in tables: + for row in table["rows"]: + dec = row["decoder"] + q = row.get("quantiles", []) + if q and any(v > 0 for v in q): + decoder_quantiles.setdefault(dec, []).append(q) + + # Fallback: synthesize quantiles from summary stats + if not decoder_quantiles: + for table in tables: + for row in table["rows"]: + dec = row["decoder"] + med = row["per_shot_median"] + p99 = row["per_shot_p99"] + mx = row["per_shot_max"] + if med > 0: + q = [med * 0.5] + [med] * 9 + [med] + [med] * 5 + [p99] * 3 + [mx, mx] + decoder_quantiles.setdefault(dec, []).append(q) + + if not decoder_quantiles: + return "" + + # Merge quantiles per decoder: geometric mean of each percentile position + merged: dict[str, list[float]] = {} + for dec, q_list in sorted(decoder_quantiles.items()): + n_q = len(q_list[0]) + result = [] + for i in range(n_q): + vals = [q[i] for q in q_list if i < len(q) and q[i] > 0] + if vals: + geo_mean = math.exp(sum(math.log(v) for v in vals) / len(vals)) + result.append(geo_mean) + else: + result.append(0.0) + merged[dec] = result + + decoders = list(merged.keys()) + all_vals = [v for qs in merged.values() for v in qs if v > 0] + if not all_vals: + return "" + + val_min = min(all_vals) * 0.3 + val_max = max(all_vals) * 3.0 + log_min = math.log10(val_min) + log_max = math.log10(val_max) + + def x_of(v: float) -> float: + if v <= 0: + return margin["left"] + frac = (math.log10(v) - log_min) / (log_max - log_min) if log_max != log_min else 0.5 + return margin["left"] + frac * plot_w + + violin_h = min(60, plot_h / len(decoders) * 0.75) + group_h = plot_h / len(decoders) + + parts = [ + f'', + f'', + f'{html_mod.escape(title)}', + ] + + # Grid lines (log scale) + for exp in range(math.floor(log_min), math.ceil(log_max) + 1): + x = x_of(10**exp) + if margin["left"] <= x <= margin["left"] + plot_w: + parts.append( + f'', + ) + parts.append( + f'1e{exp}s', + ) + + parts.append( + f'Decode time per shot (seconds, log scale)', + ) + + # Draw violins + for di, dec in enumerate(decoders): + qs = merged[dec] + cy = margin["top"] + di * group_h + group_h / 2 + color = _COLORS[di % len(_COLORS)] + + # Decoder label + parts.append( + f'{html_mod.escape(dec)}', + ) + + # Build violin shape from quantiles. + # The "width" at each quantile represents density: wider near the median, + # narrower at tails. We use a triangular kernel approximation where + # density ~ 1 / (gap between adjacent quantiles in log space). + n = len(qs) + if n < 3: + continue + + # Compute density proxy at each quantile + log_qs = [math.log10(max(v, 1e-12)) for v in qs] + densities = [] + for i in range(n): + left = log_qs[i] - log_qs[max(0, i - 1)] + right = log_qs[min(n - 1, i + 1)] - log_qs[i] + gap = left + right + densities.append(1.0 / max(gap, 0.01)) + + max_density = max(densities) if densities else 1.0 + half_h = violin_h / 2 + + # Build SVG path: top half then bottom half (mirror) + top_points = [] + bot_points = [] + for i in range(n): + if qs[i] <= 0: + continue + x = x_of(qs[i]) + dy = (densities[i] / max_density) * half_h + top_points.append((x, cy - dy)) + bot_points.append((x, cy + dy)) + + if len(top_points) < 2: + continue + + # Build path: top left-to-right, then bottom right-to-left + path_parts = [f"M{top_points[0][0]:.1f},{top_points[0][1]:.1f}"] + for x, y in top_points[1:]: + path_parts.append(f"L{x:.1f},{y:.1f}") + for x, y in reversed(bot_points): + path_parts.append(f"L{x:.1f},{y:.1f}") + path_parts.append("Z") + + parts.append( + f'', + ) + + # Median line + med_idx = n // 2 + if qs[med_idx] > 0: + mx = x_of(qs[med_idx]) + dy = (densities[med_idx] / max_density) * half_h + parts.append( + f'', + ) + parts.append( + f'' + f"{qs[med_idx]:.1e}s", + ) + + # p99 marker (index 19 of 21 = 95th percentile is close, use index ~20*0.99=19.8) + p99_idx = min(n - 2, int(n * 0.99)) + if p99_idx < n and qs[p99_idx] > 0: + px = x_of(qs[p99_idx]) + parts.append( + f'', + ) + + parts.append("") + return "\n".join(parts) + + +# -- HTML report -------------------------------------------------------------- + + +def _shots_summary(tables: list[dict]) -> str: + """Summarise shot counts across all comparison table rows.""" + all_counts = set() + for table in tables: + for row in table["rows"]: + all_counts.add(row["num_shots"]) + if not all_counts: + return "N/A" + lo, hi = min(all_counts), max(all_counts) + if lo == hi: + return str(lo) + return f"{lo} -- {hi} per point" + + +def _rounds_summary(tables: list[dict]) -> str: + """Summarise round counts per distance, e.g. 'd=3: r=[6,7,9]; d=5: r=[10,12,15]'.""" + from collections import defaultdict + + rounds_by_d: dict[int, set[int]] = defaultdict(set) + for table in tables: + rounds_by_d[table["distance"]].add(table["num_rounds"]) + if not rounds_by_d: + return "N/A" + lines = [] + for d in sorted(rounds_by_d): + rs = sorted(rounds_by_d[d]) + lines.append(html_mod.escape(f"d={d}: r=[{', '.join(str(r) for r in rs)}]")) + return "
".join(lines) + + +def _build_html(analysis: dict) -> str: + """Build the full HTML report string.""" + config = analysis.get("config", {}) + tables = analysis.get("comparison_tables", []) + curves = analysis.get("threshold_curves", []) + + style = dedent(""" + :root { + color-scheme: light dark; + --bg: #f8fafc; --fg: #0f172a; + --hero-bg: linear-gradient(135deg, #e0f2fe, #f8fafc 55%, #dcfce7); + --card-bg: white; --card-border: #dbeafe; --card-shadow: rgba(15,23,42,0.05); + --meta-bg: rgba(255,255,255,0.82); + --muted: #475569; --link: #2563eb; + --table-stripe: #f1f5f9; --table-border: #e2e8f0; + } + [data-theme="dark"] { + --bg: #0f172a; --fg: #e2e8f0; + --hero-bg: linear-gradient(135deg, #1e293b, #0f172a 55%, #1a2e1a); + --card-bg: #1e293b; --card-border: #334155; --card-shadow: rgba(0,0,0,0.3); + --meta-bg: rgba(30,41,59,0.82); + --muted: #94a3b8; --link: #60a5fa; + --table-stripe: #0f172a; --table-border: #334155; + } + @media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0f172a; --fg: #e2e8f0; + --hero-bg: linear-gradient(135deg, #1e293b, #0f172a 55%, #1a2e1a); + --card-bg: #1e293b; --card-border: #334155; --card-shadow: rgba(0,0,0,0.3); + --meta-bg: rgba(30,41,59,0.82); + --muted: #94a3b8; --link: #60a5fa; + --table-stripe: #0f172a; --table-border: #334155; + } + } + body { + margin: 0; + font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg); color: var(--fg); + } + main { max-width: 1400px; margin: 0 auto; padding: 32px 24px 56px; } + h1, h2, h3, p { margin-top: 0; } + .theme-toggle { + position: fixed; top: 16px; right: 16px; z-index: 100; + background: var(--card-bg); border: 1px solid var(--card-border); + border-radius: 8px; padding: 6px 12px; cursor: pointer; + color: var(--fg); font-size: 0.85rem; font-weight: 600; + } + .theme-toggle:hover { opacity: 0.8; } + .hero { + background: var(--hero-bg); + border: 1px solid var(--card-border); + border-radius: 20px; + padding: 24px; + margin-bottom: 24px; + } + .meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-top: 18px; + } + .meta-card { + background: var(--meta-bg); + border: 1px solid var(--card-border); + border-radius: 14px; + padding: 14px 16px; + } + .meta-card strong { + display: block; font-size: 0.82rem; text-transform: uppercase; + letter-spacing: 0.04em; color: var(--muted); margin-bottom: 6px; + } + .section { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 18px; + padding: 20px 22px; + margin-top: 20px; + box-shadow: 0 10px 24px var(--card-shadow); + overflow-x: auto; + } + .plots { + display: flex; + flex-wrap: wrap; + gap: 16px; + justify-content: center; + } + table { border-collapse: collapse; width: 100%; margin: 12px 0 0; } + th, td { padding: 10px 14px; text-align: right; border-bottom: 1px solid var(--table-border); } + th { + font-size: 0.82rem; text-transform: uppercase; + letter-spacing: 0.03em; color: var(--muted); border-bottom-width: 2px; + } + td:first-child, th:first-child { text-align: left; } + tr:nth-child(even) td { background: var(--table-stripe); } + code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + details.collapsible { margin-top: 20px; } + details.collapsible > summary { + cursor: pointer; font-size: 1.1rem; font-weight: 600; + padding: 14px 22px; + background: var(--card-bg); border: 1px solid var(--card-border); + border-radius: 18px; + box-shadow: 0 10px 24px var(--card-shadow); + list-style: none; + } + details.collapsible > summary::before { content: "\\25B6 "; font-size: 0.8em; } + details.collapsible[open] > summary::before { content: "\\25BC "; } + details.collapsible > .section { margin-top: 8px; } + """).strip() + + def meta_card(label: str, value: str, *, raw: bool = False) -> str: + val = value if raw else html_mod.escape(value) + return f'
{html_mod.escape(label)}{val}
' + + decoders = config.get("decoders", []) + p1s = config.get("p1_scale", 1 / 30) + pms = config.get("p_meas_scale", 1 / 3) + pps = config.get("p_prep_scale", 1 / 3) + noise_str = f"Depolarizing: p1={p1s:.4g}*p, p2=p, p_meas={pms:.4g}*p, p_prep={pps:.4g}*p" + + # Build global color maps for consistency across all plots + all_decoders_sorted = sorted({c["decoder"] for c in curves}) if curves else decoders + all_distances_sorted = sorted({c["distance"] for c in curves}) if curves else [] + dec_colors = {d: _COLORS[i % len(_COLORS)] for i, d in enumerate(all_decoders_sorted)} + dist_colors = {d: _COLORS[i % len(_COLORS)] for i, d in enumerate(all_distances_sorted)} + + parts = [ + "", + '', + "", + ' ', + ' ', + " PECOS Decoder Performance Report", + f" ", + "", + "", + '', + "
", + '
', + "

PECOS Decoder Performance Report

", + "

Same samples decoded by multiple decoders. LER differences reflect " + "decoder quality, not sampling noise.

", + '
', + meta_card("Decoders", ", ".join(decoders)), + meta_card("Distances", ", ".join(str(d) for d in config.get("distances", []))), + meta_card("Basis", config.get("basis", "Z")), + meta_card("Shots", _shots_summary(tables)), + meta_card("Noise Model", noise_str), + meta_card("Error Rates (p)", ", ".join(f"{p:.4g}" for p in config.get("error_rates", []))), + "
", + '
', + meta_card("Rounds", _rounds_summary(tables), raw=True), + "
", + "
", + ] + + # -- Threshold curves section -- + if curves: + # Group curves by distance and by decoder + curves_by_distance: dict[int, list[dict]] = defaultdict(list) + curves_by_decoder: dict[str, list[dict]] = defaultdict(list) + for curve in curves: + curves_by_distance[curve["distance"]].append(curve) + curves_by_decoder[curve["decoder"]].append(curve) + + threshold_estimates = analysis.get("threshold_estimates", []) + fss_per_round_est = {e["decoder"]: e for e in threshold_estimates if e.get("metric") == "fss_per_round"} + fss_d_round_est = {e["decoder"]: e for e in threshold_estimates if e.get("metric") == "fss_d_round"} + + def _threshold_table(estimates: dict) -> list[str]: + if not estimates: + return [] + lines = [] + lines.append(" ") + lines.append( + " ", + ) + for est in sorted(estimates.values(), key=lambda e: e.get("estimated_p_th", 0), reverse=True): + p_th = est["estimated_p_th"] + se = est.get("std_error") + th_str = f"{_sci(p_th)} +/- {_sci(se)}" if se else f"{_sci(p_th)}" + lines.append( + f" " + f"" + f"", + ) + lines.append("
DecoderThreshold (FSS fit)Distances
{html_mod.escape(est['decoder'])}{th_str}d={est['d_small']} -- d={est['d_large']}
") + return lines + + # === ALWAYS VISIBLE: Per-round LER === + + # Per-round by distance + parts.append('
') + parts.append("

Per-Round Logical Error Rate -- by Distance

") + parts.append( + "

All decoders compared at each distance. " + "Fitted from multiple syndrome extraction rounds per point.

", + ) + parts.append('
') + for d in sorted(curves_by_distance): + svg = _build_threshold_svg( + curves_by_distance[d], + title=f"d = {d}", + y_field="per_round_ler", + y_label="LER per round", + decoder_color_map=dec_colors, + ) + parts.append(f" {svg}") + parts.append("
") + parts.append("
") + + # Per-round by decoder (threshold view) + parts.append('
') + parts.append("

Per-Round Logical Error Rate -- by Decoder

") + parts.append( + "

Distance scaling for each decoder. " + "Curves crossing = threshold (per-round LER is distance-independent at threshold).

", + ) + parts.extend(_threshold_table(fss_per_round_est)) + parts.append('
') + for dec in sorted(curves_by_decoder): + est = fss_per_round_est.get(dec) + title = dec + p_th = None + if est: + p_th = est["estimated_p_th"] + title = f"{dec} (p_th ~ {p_th:.4f})" + svg = _build_threshold_svg( + curves_by_decoder[dec], + title=title, + color_by="distance", + threshold_p=p_th, + y_field="per_round_ler", + y_label="LER per round", + distance_color_map=dist_colors, + ) + parts.append(f" {svg}") + parts.append("
") + parts.append("
") + + # === COLLAPSIBLE: d-round LER === + parts.append('
') + parts.append(" d-Round Logical Error Rate (click to expand)") + + # d-round by distance + parts.append('
') + parts.append("

d-Round LER -- by Distance

") + parts.append("

Logical error rate over d rounds of syndrome extraction.

") + parts.append('
') + for d in sorted(curves_by_distance): + svg = _build_threshold_svg( + curves_by_distance[d], + title=f"d = {d}", + y_label="LER (d rounds)", + decoder_color_map=dec_colors, + ) + parts.append(f" {svg}") + parts.append("
") + parts.append("
") + + # d-round by decoder + parts.append('
') + parts.append("

d-Round LER -- by Decoder

") + parts.extend(_threshold_table(fss_d_round_est)) + parts.append('
') + for dec in sorted(curves_by_decoder): + est = fss_d_round_est.get(dec) + title = dec + p_th = None + if est: + p_th = est["estimated_p_th"] + title = f"{dec} (p_th ~ {p_th:.4f})" + svg = _build_threshold_svg( + curves_by_decoder[dec], + title=title, + color_by="distance", + threshold_p=p_th, + y_label="LER (d rounds)", + distance_color_map=dist_colors, + ) + parts.append(f" {svg}") + parts.append("
") + parts.append("
") + parts.append("
") + + # -- Duration curves (collapsible) -- + duration_curves = analysis.get("duration_curves", []) + if duration_curves: + # Group by physical_error_rate + dur_by_p: dict[float, list[dict]] = defaultdict(list) + for dc in duration_curves: + dur_by_p[dc["physical_error_rate"]].append(dc) + + parts.append('
') + parts.append(" Duration Curves -- LER vs Rounds (click to expand)") + parts.append('
') + parts.append( + "

Logical error rate vs number of rounds at fixed physical error rate. " + "Shows how LER grows with memory duration.

", + ) + parts.append('
') + for p in sorted(dur_by_p): + svg = _build_duration_svg(dur_by_p[p], title=f"p = {p:.4g}") + parts.append(f" {svg}") + parts.append("
") + parts.append("
") + parts.append("
") + + # -- Timing comparison section -- + if tables: + err_rates = sorted({t["physical_error_rate"] for t in tables}) + distances_available = sorted({t["distance"] for t in tables}) + + def _violin_plots_for_p(p_val: float) -> list[str]: + """Generate violin SVGs for each distance at a given error rate.""" + svgs = [] + for d in distances_available: + candidates = [t for t in tables if t["distance"] == d and t["physical_error_rate"] == p_val] + if not candidates: + continue + target_r = 2 * d + best = min(candidates, key=lambda t: abs(t["num_rounds"] - target_r)) + svg = _build_timing_svg( + [best], + title=f"d = {d}, r = {best['num_rounds']}", + ) + svgs.append(svg) + return svgs + + # Always-visible: lowest error rate + if err_rates: + lowest_p = err_rates[0] + svgs = _violin_plots_for_p(lowest_p) + if svgs: + parts.append('
') + parts.append(f"

Decode Speed (p = {lowest_p:.4g})

") + parts.append("

Per-shot decode time distribution for each decoder and distance.

") + parts.append('
') + parts.extend(f" {svg}" for svg in svgs) + parts.append("
") + parts.append("
") + + # Collapsible: all other error rates + other_rates = [p for p in err_rates if p != err_rates[0]] + if other_rates: + parts.append('
') + parts.append(" Decode Speed at Other Error Rates (click to expand)") + for p_val in other_rates: + svgs = _violin_plots_for_p(p_val) + if svgs: + parts.append('
') + parts.append(f"

p = {p_val:.4g}

") + parts.append('
') + parts.extend(f" {svg}" for svg in svgs) + parts.append("
") + parts.append("
") + parts.append("
") + + # -- Comparison tables (collapsible) -- + if tables: + parts.append('
') + parts.append(" Detailed Comparison Tables (click to expand)") + for table in tables: + d = table["distance"] + p = table["physical_error_rate"] + r = table["num_rounds"] + n = table["num_shots"] + + parts.append('
') + parts.append(f"

d={d}, p={p:.4g}, rounds={r} ({n} shots)

") + parts.append(" ") + parts.append( + " " + "", + ) + + for row in table["rows"]: + ler_str = f"{row['logical_error_rate']:.4f} ({row['ci_low']:.4f} - {row['ci_high']:.4f})" + sps = row["num_shots"] / row["per_shot_median"] if row["per_shot_median"] > 0 else float("inf") + parts.append( + f" " + f"" + f"" + f"" + f"" + f"" + f"" + f"", + ) + + parts.append("
DecoderLER (95% CI)Medianp99MaxThroughput
{html_mod.escape(row['decoder'])}{ler_str}{row['per_shot_median']:.1e} s{row['per_shot_p99']:.1e} s{row['per_shot_max']:.1e} s{sps:.1e} shots/s
") + parts.append("
") + parts.append("
") + + parts.extend( + [ + "
", + dedent(""" + + """).strip(), + "", + "", + ], + ) + + return "\n".join(parts) + + +# -- PDF report (matplotlib) -------------------------------------------------- + + +def _build_pdf(analysis: dict, output_path: Path) -> None: + """Build a multi-page PDF report using matplotlib.""" + import matplotlib.pyplot as plt + from matplotlib.backends.backend_pdf import PdfPages + + config = analysis.get("config", {}) + tables = analysis.get("comparison_tables", []) + curves = analysis.get("threshold_curves", []) + + page_size = (11, 8.5) + + with PdfPages(output_path) as pdf: + # -- Cover page -- + fig, ax = plt.subplots(figsize=page_size) + ax.axis("off") + ax.text( + 0.5, + 0.65, + "PECOS Decoder Performance Report", + transform=ax.transAxes, + fontsize=24, + ha="center", + va="center", + fontweight="bold", + ) + + info_lines = [ + f"Decoders: {', '.join(config.get('decoders', []))}", + f"Distances: {config.get('distances', [])}", + f"Error rates: {config.get('error_rates', [])}", + f"Shots: {config.get('shots', 'N/A')}", + f"Basis: {config.get('basis', 'Z')}", + ] + ax.text( + 0.5, + 0.40, + "\n".join(info_lines), + transform=ax.transAxes, + fontsize=12, + ha="center", + va="center", + family="monospace", + ) + pdf.savefig(fig) + plt.close(fig) + + # -- Threshold curve plots -- + if curves: + curves_by_distance: dict[int, list[dict]] = defaultdict(list) + curves_by_decoder: dict[str, list[dict]] = defaultdict(list) + for curve in curves: + curves_by_distance[curve["distance"]].append(curve) + curves_by_decoder[curve["decoder"]].append(curve) + + def _plot_curves( + ax: plt.Axes, + curve_list: list[dict], + title: str, + color_by: str = "decoder", + ) -> None: + ax.set_title(title, fontsize=14, fontweight="bold") + ax.set_xlabel("Physical error rate (p)") + ax.set_ylabel("Logical error rate") + ax.set_yscale("log") + ax.grid(visible=True, alpha=0.3) + decoder_names = list(dict.fromkeys(c["decoder"] for c in curve_list)) + dists = sorted({c["distance"] for c in curve_list}) + linestyles = ["-", "--", ":", "-."] + for curve in sorted(curve_list, key=lambda c: (decoder_names.index(c["decoder"]), c["distance"])): + pts = [pt for pt in curve["points"] if pt["logical_error_rate"] > 0] + if not pts: + continue + ps = [pt["physical_error_rate"] for pt in pts] + lers = [pt["logical_error_rate"] for pt in pts] + ci_lows = [pt["ci_low"] for pt in pts] + ci_highs = [pt["ci_high"] for pt in pts] + dec_idx = decoder_names.index(curve["decoder"]) + dist_idx = dists.index(curve["distance"]) + if color_by == "distance": + color = _COLORS[dist_idx % len(_COLORS)] + ls = linestyles[dec_idx % len(linestyles)] + else: + color = _COLORS[dec_idx % len(_COLORS)] + ls = linestyles[dist_idx % len(linestyles)] + label = f"{curve['decoder']} d={curve['distance']}" + ax.plot(ps, lers, marker="o", linestyle=ls, color=color, label=label, markersize=4) + ax.fill_between(ps, ci_lows, ci_highs, color=color, alpha=0.15) + ax.legend(fontsize=9, loc="best") + + # Per-distance plots (all decoders) + for d in sorted(curves_by_distance): + fig, ax = plt.subplots(figsize=page_size) + _plot_curves(ax, curves_by_distance[d], f"By Distance -- d = {d}") + fig.tight_layout() + pdf.savefig(fig) + plt.close(fig) + + # Per-decoder plots (all distances -- color by distance, threshold line) + threshold_estimates = analysis.get("threshold_estimates", []) + est_by_dec = {e["decoder"]: e for e in threshold_estimates} + for dec in sorted(curves_by_decoder): + fig, ax = plt.subplots(figsize=page_size) + est = est_by_dec.get(dec) + title = f"By Decoder -- {dec}" + if est: + title += f" (p_th ~ {est['estimated_p_th']:.4f})" + _plot_curves(ax, curves_by_decoder[dec], title, color_by="distance") + if est: + ax.axvline( + est["estimated_p_th"], + color="#334155", + linestyle=":", + linewidth=1.8, + alpha=0.7, + zorder=0, + ) + fig.tight_layout() + pdf.savefig(fig) + plt.close(fig) + + # -- Duration curve plots -- + duration_curves = analysis.get("duration_curves", []) + if duration_curves: + dur_by_p: dict[float, list[dict]] = defaultdict(list) + for dc in duration_curves: + dur_by_p[dc["physical_error_rate"]].append(dc) + + for p_val in sorted(dur_by_p): + fig, ax = plt.subplots(figsize=page_size) + ax.set_title(f"Duration -- p = {p_val:.4g}", fontsize=14, fontweight="bold") + ax.set_xlabel("Rounds") + ax.set_ylabel("Logical error rate") + ax.set_yscale("log") + ax.grid(visible=True, alpha=0.3) + + for ci, dc in enumerate(dur_by_p[p_val]): + pts = [pt for pt in dc["points"] if pt["logical_error_rate"] > 0] + if not pts: + continue + rs = [pt["num_rounds"] for pt in pts] + lers = [pt["logical_error_rate"] for pt in pts] + ci_lows = [pt["ci_low"] for pt in pts] + ci_highs = [pt["ci_high"] for pt in pts] + color = _COLORS[ci % len(_COLORS)] + label = f"{dc['decoder']} d={dc['distance']}" + ax.plot(rs, lers, "o-", color=color, label=label, markersize=4) + ax.fill_between(rs, ci_lows, ci_highs, color=color, alpha=0.15) + + ax.legend(fontsize=9, loc="best") + fig.tight_layout() + pdf.savefig(fig) + plt.close(fig) + + # -- Comparison tables as figures -- + for table in tables: + fig, ax = plt.subplots(figsize=page_size) + ax.axis("off") + ax.set_title( + f"d={table['distance']}, p={table['physical_error_rate']:.4g}, " + f"rounds={table['num_rounds']} ({table['num_shots']} shots)", + fontsize=14, + fontweight="bold", + pad=20, + ) + + col_labels = ["Decoder", "LER", "95% CI", "Median", "p99", "Max"] + cell_data = [ + [ + row["decoder"], + f"{row['logical_error_rate']:.4f}", + f"{row['ci_low']:.4f} - {row['ci_high']:.4f}", + f"{row['per_shot_median']:.1e} s", + f"{row['per_shot_p99']:.1e} s", + f"{row['per_shot_max']:.1e} s", + ] + for row in table["rows"] + ] + + if cell_data: + tbl = ax.table( + cellText=cell_data, + colLabels=col_labels, + loc="center", + cellLoc="center", + ) + tbl.auto_set_font_size(value=False) + tbl.set_fontsize(10) + tbl.scale(1.0, 1.8) + + # Style header row + for j in range(len(col_labels)): + tbl[0, j].set_facecolor("#e2e8f0") + tbl[0, j].set_text_props(fontweight="bold") + + fig.tight_layout() + pdf.savefig(fig) + plt.close(fig) + + +# -- Markdown report (Obsidian-compatible) ------------------------------------ + + +def _build_markdown(analysis: dict, plots_dir: Path) -> str: + """Build an Obsidian-compatible Markdown report with standalone SVG plots.""" + config = analysis.get("config", {}) + tables = analysis.get("comparison_tables", []) + curves = analysis.get("threshold_curves", []) + + decoders = config.get("decoders", []) + p1s = config.get("p1_scale", 1 / 30) + pms = config.get("p_meas_scale", 1 / 3) + pps = config.get("p_prep_scale", 1 / 3) + noise_str = f"Depolarizing: p1={p1s:.4g}*p, p2=p, p_meas={pms:.4g}*p, p_prep={pps:.4g}*p" + + # Global color maps + all_decoders_sorted = sorted({c["decoder"] for c in curves}) if curves else decoders + all_distances_sorted = sorted({c["distance"] for c in curves}) if curves else [] + dec_colors = {d: _COLORS[i % len(_COLORS)] for i, d in enumerate(all_decoders_sorted)} + dist_colors = {d: _COLORS[i % len(_COLORS)] for i, d in enumerate(all_distances_sorted)} + + # Rounds summary + rounds_by_d: dict[int, set[int]] = defaultdict(set) + for table in tables: + rounds_by_d[table["distance"]].add(table["num_rounds"]) + + def _save_svg(svg_content: str, name: str) -> str: + """Save SVG to plots_dir and return relative path.""" + filename = f"{name}.svg" + (plots_dir / filename).write_text(svg_content) + return f"plots/{filename}" + + lines = [] + + # -- Frontmatter -- + lines.append("---") + lines.append("title: PECOS Decoder Performance Report") + lines.append("tags: [report, surface-code, decoders]") + lines.append(f"decoders: [{', '.join(decoders)}]") + lines.append(f"distances: [{', '.join(str(d) for d in config.get('distances', []))}]") + lines.append(f"date: {__import__('datetime').date.today().isoformat()}") + lines.append("---") + lines.append("") + + # -- Header -- + lines.append("# PECOS Decoder Performance Report") + lines.append("") + lines.append("> [!info] Configuration") + lines.append(f"> **Decoders:** {', '.join(decoders)}") + lines.append(f"> **Distances:** {', '.join(str(d) for d in config.get('distances', []))}") + lines.append(f"> **Error Rates (p):** {', '.join(f'{p:.4g}' for p in config.get('error_rates', []))}") + lines.append(f"> **Shots:** {_shots_summary(tables)}") + lines.append(f"> **Noise Model:** {noise_str}") + lines.append(f"> **Basis:** {config.get('basis', 'Z')}") + for d in sorted(rounds_by_d): + rs = sorted(rounds_by_d[d]) + lines.append(f"> **d={d}:** r=[{', '.join(str(r) for r in rs)}]") + lines.append("") + + if curves: + curves_by_distance: dict[int, list[dict]] = defaultdict(list) + curves_by_decoder: dict[str, list[dict]] = defaultdict(list) + for curve in curves: + curves_by_distance[curve["distance"]].append(curve) + curves_by_decoder[curve["decoder"]].append(curve) + + threshold_estimates = analysis.get("threshold_estimates", []) + fss_per_round_est = {e["decoder"]: e for e in threshold_estimates if e.get("metric") == "fss_per_round"} + fss_d_round_est = {e["decoder"]: e for e in threshold_estimates if e.get("metric") == "fss_d_round"} + + # -- Per-round by distance -- + lines.append("## Per-Round LER -- by Distance") + lines.append("") + for d in sorted(curves_by_distance): + svg = _build_threshold_svg( + curves_by_distance[d], + title=f"d = {d}", + y_field="per_round_ler", + y_label="LER per round", + decoder_color_map=dec_colors, + ) + path = _save_svg(svg, f"per_round_by_dist_d{d}") + lines.append(f"![d={d}]({path})") + lines.append("") + + # -- Per-round by decoder with thresholds -- + lines.append("## Per-Round LER -- by Decoder") + lines.append("") + + if fss_per_round_est: + lines.append("### Threshold Estimates (per-round)") + lines.append("") + lines.append("| Decoder | Threshold | Distances |") + lines.append("|---------|-----------|-----------|") + lines.extend( + f"| {est['decoder']} | {est['estimated_p_th']:.4f} " + f"| d={est['d_small']} / d={est['d_large']} crossing |" + for est in sorted(fss_per_round_est.values(), key=lambda e: e["estimated_p_th"], reverse=True) + ) + lines.append("") + + for dec in sorted(curves_by_decoder): + est = fss_per_round_est.get(dec) + title = dec + p_th = None + if est: + p_th = est["estimated_p_th"] + title = f"{dec} (p_th ~ {p_th:.4f})" + svg = _build_threshold_svg( + curves_by_decoder[dec], + title=title, + color_by="distance", + threshold_p=p_th, + y_field="per_round_ler", + y_label="LER per round", + distance_color_map=dist_colors, + ) + path = _save_svg(svg, f"per_round_by_dec_{dec.replace(':', '_')}") + lines.append(f"![{dec}]({path})") + lines.append("") + + # -- d-round LER (collapsible) -- + lines.append("> [!note]- d-Round Logical Error Rate (click to expand)") + lines.append(">") + lines.append("> ### d-Round LER -- by Distance") + lines.append(">") + for d in sorted(curves_by_distance): + svg = _build_threshold_svg( + curves_by_distance[d], + title=f"d = {d}", + y_label="LER (d rounds)", + decoder_color_map=dec_colors, + ) + path = _save_svg(svg, f"d_round_by_dist_d{d}") + lines.append(f"> ![d={d}]({path})") + lines.append(">") + + if fss_d_round_est: + lines.append("> ### Threshold Estimates (d-round)") + lines.append(">") + lines.append("> | Decoder | Threshold | Distances |") + lines.append("> |---------|-----------|-----------|") + lines.extend( + f"> | {est['decoder']} | {est['estimated_p_th']:.4f} " + f"| d={est['d_small']} / d={est['d_large']} crossing |" + for est in sorted(fss_d_round_est.values(), key=lambda e: e["estimated_p_th"], reverse=True) + ) + lines.append(">") + + for dec in sorted(curves_by_decoder): + est = fss_d_round_est.get(dec) + title = dec + p_th = None + if est: + p_th = est["estimated_p_th"] + title = f"{dec} (p_th ~ {p_th:.4f})" + svg = _build_threshold_svg( + curves_by_decoder[dec], + title=title, + color_by="distance", + threshold_p=p_th, + y_label="LER (d rounds)", + distance_color_map=dist_colors, + ) + path = _save_svg(svg, f"d_round_by_dec_{dec.replace(':', '_')}") + lines.append(f"> ![{dec}]({path})") + lines.append(">") + lines.append("") + + # -- Duration curves (collapsible) -- + duration_curves = analysis.get("duration_curves", []) + if duration_curves: + dur_by_p: dict[float, list[dict]] = defaultdict(list) + for dc in duration_curves: + dur_by_p[dc["physical_error_rate"]].append(dc) + + lines.append("> [!note]- Duration Curves -- LER vs Rounds (click to expand)") + lines.append(">") + for p in sorted(dur_by_p): + svg = _build_duration_svg(dur_by_p[p], title=f"p = {p:.4g}") + path = _save_svg(svg, f"duration_p{p:.4g}".replace(".", "_")) + lines.append(f"> ![p={p:.4g}]({path})") + lines.append(">") + lines.append("") + + # -- Decode speed -- + if tables: + err_rates = sorted({t["physical_error_rate"] for t in tables}) + distances_available = sorted({t["distance"] for t in tables}) + + if err_rates: + lowest_p = err_rates[0] + lines.append(f"## Decode Speed (p = {lowest_p:.4g})") + lines.append("") + for d in distances_available: + candidates = [t for t in tables if t["distance"] == d and t["physical_error_rate"] == lowest_p] + if not candidates: + continue + target_r = 2 * d + best = min(candidates, key=lambda t: abs(t["num_rounds"] - target_r)) + svg = _build_timing_svg([best], title=f"d = {d}, r = {best['num_rounds']}") + path = _save_svg(svg, f"timing_d{d}_p{lowest_p:.4g}".replace(".", "_")) + lines.append(f"![d={d}]({path})") + lines.append("") + + # Other error rates collapsible + other_rates = [p for p in err_rates if p != err_rates[0]] if err_rates else [] + if other_rates: + lines.append("> [!note]- Decode Speed at Other Error Rates (click to expand)") + lines.append(">") + for p_val in other_rates: + lines.append(f"> **p = {p_val:.4g}**") + lines.append(">") + for d in distances_available: + candidates = [t for t in tables if t["distance"] == d and t["physical_error_rate"] == p_val] + if not candidates: + continue + target_r = 2 * d + best = min(candidates, key=lambda t: abs(t["num_rounds"] - target_r)) + svg = _build_timing_svg([best], title=f"d = {d}, r = {best['num_rounds']}") + path = _save_svg(svg, f"timing_d{d}_p{p_val:.4g}".replace(".", "_")) + lines.append(f"> ![d={d}]({path})") + lines.append(">") + lines.append("") + + # -- Comparison tables (collapsible) -- + if tables: + lines.append("> [!note]- Detailed Comparison Tables (click to expand)") + lines.append(">") + for table in tables: + d = table["distance"] + p = table["physical_error_rate"] + r = table["num_rounds"] + n = table["num_shots"] + lines.append(f"> ### d={d}, p={p:.4g}, rounds={r} ({n} shots)") + lines.append(">") + lines.append("> | Decoder | LER (95% CI) | Median | p99 | Max | Throughput |") + lines.append("> |---------|-------------|--------|-----|-----|------------|") + for row in table["rows"]: + ler_str = f"{row['logical_error_rate']:.4f} ({row['ci_low']:.4f} - {row['ci_high']:.4f})" + sps = row["num_shots"] / row["per_shot_median"] if row["per_shot_median"] > 0 else float("inf") + lines.append( + f"> | {row['decoder']} | {ler_str} " + f"| {row['per_shot_median']:.1e} s " + f"| {row['per_shot_p99']:.1e} s " + f"| {row['per_shot_max']:.1e} s " + f"| {sps:.1e} shots/s |", + ) + lines.append(">") + lines.append("") + + return "\n".join(lines) + + +# -- CLI ---------------------------------------------------------------------- + + +def main() -> int: + """CLI entry point for report generation.""" + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("analysis", type=Path, help="Analysis JSON from analyze_data.py") + parser.add_argument("--html", action="store_true", help="Generate HTML report") + parser.add_argument("--pdf", action="store_true", help="Generate PDF report (requires matplotlib)") + parser.add_argument("--markdown", action="store_true", help="Generate Obsidian-compatible Markdown report") + parser.add_argument("--open", action="store_true", help="Open HTML report in browser") + parser.add_argument("-o", "--output-dir", type=str, default=None) + args = parser.parse_args() + + if not args.html and not args.pdf and not args.markdown: + args.html = True # default to HTML + + analysis = _load_analysis(args.analysis) + + out = Path(args.output_dir) if args.output_dir else args.analysis.parent + out.mkdir(parents=True, exist_ok=True) + + if args.html: + html_path = out / "report.html" + html_path.write_text(_build_html(analysis)) + print(f"Wrote {html_path}") + + if args.open: + import webbrowser + + webbrowser.open(html_path.as_uri()) + print(f"Opened {html_path}") + + if args.pdf: + pdf_path = out / "report.pdf" + _build_pdf(analysis, pdf_path) + print(f"Wrote {pdf_path}") + + if args.markdown: + plots_dir = out / "plots" + plots_dir.mkdir(parents=True, exist_ok=True) + md_path = out / "report.md" + md_path.write_text(_build_markdown(analysis, plots_dir)) + n_plots = len(list(plots_dir.glob("*.svg"))) + print(f"Wrote {md_path} ({n_plots} SVG plots in {plots_dir}/)") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/surface/coherent_noise_sweep.py b/examples/surface/coherent_noise_sweep.py new file mode 100644 index 000000000..1995878c6 --- /dev/null +++ b/examples/surface/coherent_noise_sweep.py @@ -0,0 +1,313 @@ +r"""Coherent idle noise sweep: surface code memory with RZ phase accumulation. + +Studies the impact of coherent Z-phase noise (RZ rotation after each CX gate) +on surface code memory experiments. Uses sim_neo for Rust-native simulation +with composable noise model (depolarizing + coherent idle RZ). + +The coherent noise models uncompensated phase accumulation during idle time +between gates. Unlike stochastic Z errors (which scale as p_z per gate), +coherent RZ rotations accumulate constructively, causing error rates far +higher than the Pauli-twirled equivalent sin²(θ/2). + +Example: + uv run python examples/surface/coherent_noise_sweep.py \ + --distance 3 --rounds 3 --basis X \ + --p-depol 0.003 \ + --p-idle 0.0 0.01 0.03 0.05 0.07 0.1 \ + --shots 10000 --save-html --open +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python" / "quantum-pecos" / "src")) + + +@dataclass +class CoherentNoisePoint: + p_idle: float + p_twirled: float + ler: float + errors: int + shots: int + standard_error: float + sim_seconds: float + decode_seconds: float + + +@dataclass +class CoherentNoiseSweep: + distance: int + rounds: int + basis: str + p_depol: float + backend: str + points: list[CoherentNoisePoint] = field(default_factory=list) + total_seconds: float = 0.0 + + +def run_sweep( + *, + distance: int, + rounds: int, + basis: str, + p_depol: float, + p_idle_values: list[float], + shots: int, + seed: int, + backend: str, + lazy_measure: bool, + max_bond_dim: int, +) -> 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_exp import depolarizing, sim_neo, stab_mps, statevec + + patch = SurfacePatch.create(distance=distance) + b = LogicalCircuitBuilder() + b.add_patch(patch, "Q0") + b.add_memory("Q0", rounds=rounds, basis=basis) + tc = b.to_tick_circuit() + + det_json = json.loads(tc.get_meta("detectors")) + obs_json = json.loads(tc.get_meta("observables")) + 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") + + sweep = CoherentNoiseSweep( + distance=distance, + rounds=rounds, + basis=basis, + p_depol=p_depol, + backend=backend, + ) + t_total = time.perf_counter() + + for p_idle in p_idle_values: + # Simulate + t0 = time.perf_counter() + noise = depolarizing().p1(p_depol).p2(p_depol).p_meas(p_depol).p_prep(p_depol).idle_rz(p_idle) + builder = sim_neo(tc).noise(noise).shots(shots).seed(seed) + if backend == "stabmps": + builder = builder.quantum( + ( + stab_mps().lazy_measure().max_bond_dim(max_bond_dim) + if lazy_measure + else stab_mps().max_bond_dim(max_bond_dim) + ), + ) + else: + builder = builder.quantum(statevec()) + results = builder.run() + sim_time = time.perf_counter() - t0 + + # Decode + t0 = time.perf_counter() + errors = 0 + for r in results: + meas = list(r) + det_events = [] + for det in det_json: + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + det_events.append(val) + obs_mask = 0 + for obs in obs_json: + val = 0 + for rec in obs["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + obs_mask |= 1 << obs["id"] + pred = osd.decode([int(x) for x in det_events]) + if pred != obs_mask: + errors += 1 + decode_time = time.perf_counter() - t0 + + ler = errors / shots + se = math.sqrt(ler * (1 - ler) / shots) if shots > 0 else 0 + p_twirled = math.sin(p_idle / 2) ** 2 + + point = CoherentNoisePoint( + p_idle=p_idle, + p_twirled=p_twirled, + ler=ler, + errors=errors, + shots=shots, + standard_error=se, + sim_seconds=sim_time, + decode_seconds=decode_time, + ) + sweep.points.append(point) + print( + f" p_idle={p_idle:.3f} sin²(θ/2)={p_twirled:.5f} " + f"LER={ler:.4f} ± {se:.4f} ({errors}/{shots}) " + f"sim={sim_time:.1f}s decode={decode_time:.1f}s", + flush=True, + ) + + sweep.total_seconds = time.perf_counter() - t_total + return sweep + + +def write_html_report(sweep: CoherentNoiseSweep, path: Path) -> None: + """Write an HTML report from the sweep results.""" + html_parts = [ + "", + "Coherent Idle Noise Sweep", + "", + f"

Coherent Idle Noise: {sweep.basis}-basis Memory d={sweep.distance}

", + f"

Depolarizing: p={sweep.p_depol}, rounds={sweep.rounds}, backend={sweep.backend}

", + f"

Total time: {sweep.total_seconds:.1f}s

", + "", + "
About Coherent Idle Noise", + '
', + "

After each two-qubit gate (CX), an RZ(θ) rotation is applied to both " + "qubits, modeling uncompensated phase accumulation during idle time. Unlike " + "stochastic Z errors, coherent rotations accumulate constructively across gates, " + "causing logical error rates far higher than the Pauli-twirled equivalent " + "sin²(θ/2).

", + "

The decoder uses a stochastic-only DEM (no knowledge of coherent noise). " + "The gap between the coherent LER and the twirled LER measures how much the " + "decoder is mismatched to the actual noise.

", + "
", + "", + "

Results

", + "", + "", + "", + "", + "", + "", + "", + "", + ] + + for pt in sweep.points: + cls = "good" if pt.ler < 0.05 else "bad" + html_parts.append( + f"" + f"" + f'' + f"" + f"" + f"", + ) + + html_parts.append("
θ (rad)sin²(θ/2)LER± SEErrorsShots
{pt.p_idle:.3f}{pt.p_twirled:.5f}{pt.ler:.4f}{pt.standard_error:.4f}{pt.errors}{pt.shots}
") + + # Amplification factor + if len(sweep.points) >= 2 and sweep.points[0].ler > 0: + baseline = sweep.points[0].ler + html_parts.append("

Coherent Amplification

") + html_parts.append("") + for pt in sweep.points[1:]: + ratio = pt.ler / baseline + twirl_ratio = pt.ler / pt.p_twirled if pt.p_twirled > 0 else 0 + html_parts.append( + f"", + ) + html_parts.append("
θLER / baselineLER / twirled
{pt.p_idle:.3f}{ratio:.1f}x{twirl_ratio:.0f}x
") + + html_parts.append("") + path.write_text("\n".join(html_parts)) + print(f"Report written to {path}") + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--distance", "-d", type=int, default=3) + parser.add_argument("--rounds", type=int, default=None, help="Syndrome rounds (default: distance)") + parser.add_argument( + "--basis", + choices=["X", "Z"], + default="X", + help="Memory basis (default: X, where RZ noise is visible)", + ) + parser.add_argument("--p-depol", type=float, default=0.003) + parser.add_argument("--p-idle", type=float, nargs="+", default=[0.0, 0.01, 0.02, 0.03, 0.05, 0.07, 0.1]) + parser.add_argument("--shots", type=int, default=10000) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--backend", choices=["statevec", "stabmps"], default="statevec") + parser.add_argument("--lazy-measure", action="store_true", default=True) + parser.add_argument("--max-bond-dim", type=int, default=128) + parser.add_argument("--output-dir", type=Path, default=Path("/tmp/coherent_noise")) + parser.add_argument("--save-json", action="store_true") + parser.add_argument("--save-html", action="store_true") + parser.add_argument("--open", action="store_true") + args = parser.parse_args() + + if args.rounds is None: + args.rounds = args.distance + + print( + f"Coherent idle noise sweep: {args.basis}-basis memory d={args.distance}, " + f"rounds={args.rounds}, p_depol={args.p_depol}", + flush=True, + ) + print( + f"Backend: {args.backend}, shots={args.shots}, seed={args.seed}", + flush=True, + ) + print(flush=True) + + sweep = run_sweep( + distance=args.distance, + rounds=args.rounds, + basis=args.basis, + p_depol=args.p_depol, + p_idle_values=args.p_idle, + shots=args.shots, + seed=args.seed, + backend=args.backend, + lazy_measure=args.lazy_measure, + max_bond_dim=args.max_bond_dim, + ) + + print(f"\nTotal: {sweep.total_seconds:.1f}s", flush=True) + + args.output_dir.mkdir(parents=True, exist_ok=True) + + if args.save_json: + json_path = args.output_dir / "coherent_noise_results.json" + json_path.write_text(json.dumps(asdict(sweep), indent=2)) + print(f"JSON written to {json_path}") + + if args.save_html or args.open: + html_path = args.output_dir / "coherent_noise_report.html" + write_html_report(sweep, html_path) + if args.open: + import webbrowser + + webbrowser.open(str(html_path)) + + +if __name__ == "__main__": + main() diff --git a/examples/surface/d3_fault_catalog_lookup.rs b/examples/surface/d3_fault_catalog_lookup.rs new file mode 100644 index 000000000..82b01fe45 --- /dev/null +++ b/examples/surface/d3_fault_catalog_lookup.rs @@ -0,0 +1,597 @@ +// Copyright 2026 The PECOS Developers +// Licensed under the Apache License, Version 2.0 + +//! Build a truncated maximum-likelihood lookup table from the Rust fault catalog. +//! +//! This example keeps the expensive loop in Rust: +//! - build a d=3 rotated surface-code Z-memory experiment, +//! - enumerate all k-fault configurations for k <= `max_faults`, +//! - XOR detector / observable effects via `fault_configurations(k)`, +//! - aggregate `configuration_probability` into a lookup table. +//! +//! The circuit builder below intentionally uses a simple sequential stabilizer +//! extraction schedule. The point of the example is the fault-catalog lookup +//! aggregation path, not the optimized surface-code scheduling used by the +//! larger sweep scripts. +//! +//! Run from the PECOS repo root: +//! +//! ```text +//! cargo run -p pecos-qec --example surface_d3_fault_catalog_lookup +//! ``` + +use pecos_qec::SurfaceCode; +use pecos_qec::fault_tolerance::fault_sampler::{ + FaultCatalog, StochasticNoiseParams, build_fault_catalog, +}; +use pecos_quantum::{Attribute, TickCircuit, TickMeasRef}; +use std::collections::BTreeMap; +use std::time::Instant; + +type Syndrome = Vec; +type Logical = Vec; +type LogicalWeights = BTreeMap; +type LookupWeights = BTreeMap; + +#[derive(Debug)] +struct MemoryCircuit { + circuit: TickCircuit, + num_detectors: usize, + num_observables: usize, +} + +fn main() -> Result<(), Box> { + let distance = 3; + let rounds = 3; + let max_faults = 2; + let p = 0.001; + + let memory = build_d3_z_memory_circuit(rounds)?; + let noise = StochasticNoiseParams { + p1: p / 10.0, + p2: p, + p_meas: p, + p_prep: p, + }; + + println!("d={distance} rotated surface-code Z-memory experiment"); + println!( + "rounds={rounds}, detectors={}, observables={}", + memory.num_detectors, memory.num_observables + ); + println!( + "noise: p1={:.3e}, p2={:.3e}, p_meas={:.3e}, p_prep={:.3e}", + noise.p1, noise.p2, noise.p_meas, noise.p_prep + ); + + let catalog = build_fault_catalog(&memory.circuit, &noise)?; + let total_alternatives: usize = catalog + .locations + .iter() + .map(|loc| loc.num_alternatives) + .sum(); + + println!( + "catalog: {} locations, {total_alternatives} single-location alternatives", + catalog.locations.len() + ); + + let started = Instant::now(); + let (weights, configs_by_weight) = build_lookup_weights(&catalog, max_faults); + let decoder = choose_most_likely_logicals(&weights); + let elapsed = started.elapsed(); + + println!("enumerated configurations:"); + for (k, count) in configs_by_weight { + println!(" k={k}: {count}"); + } + println!( + "lookup table: {} syndromes covered, built in {:.3?}", + decoder.len(), + elapsed + ); + + print_top_syndromes(&weights, &decoder, 10); + + Ok(()) +} + +fn build_lookup_weights( + catalog: &FaultCatalog, + max_faults: usize, +) -> (LookupWeights, Vec<(usize, usize)>) { + let mut weights: LookupWeights = BTreeMap::new(); + let mut configs_by_weight = Vec::new(); + + for k in 0..=max_faults { + let mut count = 0usize; + for event in catalog.fault_configurations(k) { + add_lookup_weight( + &mut weights, + event.affected_detectors, + event.affected_observables, + event.configuration_probability, + ); + count += 1; + } + configs_by_weight.push((k, count)); + } + + (weights, configs_by_weight) +} + +fn add_lookup_weight( + weights: &mut LookupWeights, + syndrome: Syndrome, + logical: Logical, + probability: f64, +) { + weights + .entry(syndrome) + .or_default() + .entry(logical) + .and_modify(|p| *p += probability) + .or_insert(probability); +} + +fn choose_most_likely_logicals(weights: &LookupWeights) -> BTreeMap { + weights + .iter() + .map(|(syndrome, logical_weights)| { + let best_logical = logical_weights + .iter() + .max_by(|(_, a), (_, b)| a.total_cmp(b)) + .map(|(logical, _)| logical.clone()) + .unwrap_or_default(); + (syndrome.clone(), best_logical) + }) + .collect() +} + +fn print_top_syndromes( + weights: &LookupWeights, + decoder: &BTreeMap, + limit: usize, +) { + let mut rows: Vec<_> = weights + .iter() + .map(|(syndrome, logical_weights)| { + let total_p: f64 = logical_weights.values().sum(); + (total_p, syndrome, logical_weights) + }) + .collect(); + rows.sort_by(|(a, _, _), (b, _, _)| b.total_cmp(a)); + + println!(); + println!("top {limit} syndrome classes by truncated probability:"); + for (rank, (total_p, syndrome, logical_weights)) in rows.into_iter().take(limit).enumerate() { + let correction = decoder.get(syndrome).cloned().unwrap_or_default(); + let no_logical = logical_weights.get(&Vec::new()).copied().unwrap_or(0.0); + let logical_total = total_p - no_logical; + println!( + " {:>2}. syndrome={:?} total={:.6e} logical_weight={:.6e} correction={:?}", + rank + 1, + syndrome, + total_p, + logical_total, + correction + ); + } +} + +fn build_d3_z_memory_circuit(rounds: usize) -> Result { + let code = SurfaceCode::rotated(3)?; + let num_data = code.num_data_qubits(); + let x_ancilla_offset = num_data; + let z_ancilla_offset = x_ancilla_offset + code.num_x_stabilizers(); + + let x_ancilla = |idx: usize| x_ancilla_offset + idx; + let z_ancilla = |idx: usize| z_ancilla_offset + idx; + + let mut circuit = TickCircuit::new(); + let data_qubits: Vec = (0..num_data).collect(); + circuit.tick().pz(&data_qubits); + + let mut x_round_measurements: Vec> = Vec::with_capacity(rounds); + let mut z_round_measurements: Vec> = Vec::with_capacity(rounds); + + for _round in 0..rounds { + let x_ancillas: Vec = (0..code.num_x_stabilizers()).map(x_ancilla).collect(); + let z_ancillas: Vec = (0..code.num_z_stabilizers()).map(z_ancilla).collect(); + + circuit.tick().pz(&x_ancillas); + circuit.tick().pz(&z_ancillas); + circuit.tick().h(&x_ancillas); + + for check in code.x_stabilizers() { + let anc = x_ancilla(check.index); + for data in check.qubits() { + circuit.tick().cx(&[(anc, data)]); + } + } + + for check in code.z_stabilizers() { + let anc = z_ancilla(check.index); + for data in check.qubits() { + circuit.tick().cx(&[(data, anc)]); + } + } + + circuit.tick().h(&x_ancillas); + + let x_refs = circuit.tick().mz(&x_ancillas); + let z_refs = circuit.tick().mz(&z_ancillas); + x_round_measurements.push(x_refs); + z_round_measurements.push(z_refs); + } + + let final_data_measurements = circuit.tick().mz(&data_qubits); + let num_measurements = circuit.num_measurements(); + + let mut detectors: Vec> = Vec::new(); + + // Initial Z-basis boundary detectors: data starts in |0...0>, so the first + // Z-check round is deterministic. Without these, an initial data X fault can + // flip every repeated Z-check round and the final data parity, cancelling all + // later detectors. + for &meas_ref in z_round_measurements[0] + .iter() + .take(code.num_z_stabilizers()) + { + detectors.push(relative_records(num_measurements, &[meas_ref])); + } + + // Repeated syndrome detectors: current stabilizer measurement XOR previous + // stabilizer measurement. These are deterministic after the first round. + for round in 1..rounds { + for (¤t, &previous) in x_round_measurements[round] + .iter() + .zip(x_round_measurements[round - 1].iter()) + .take(code.num_x_stabilizers()) + { + detectors.push(relative_records(num_measurements, &[current, previous])); + } + for (¤t, &previous) in z_round_measurements[round] + .iter() + .zip(z_round_measurements[round - 1].iter()) + .take(code.num_z_stabilizers()) + { + detectors.push(relative_records(num_measurements, &[current, previous])); + } + } + + // Final Z-basis detectors: last Z-stabilizer result XOR the final data + // measurements in that stabilizer support. + let last_round = rounds - 1; + for check in code.z_stabilizers() { + let mut refs = vec![z_round_measurements[last_round][check.index]]; + refs.extend( + check + .qubits() + .into_iter() + .map(|q| final_data_measurements[q]), + ); + detectors.push(relative_records(num_measurements, &refs)); + } + + let logical_z_refs: Vec = code + .logical_z() + .data_qubits + .iter() + .map(|&q| final_data_measurements[q]) + .collect(); + let observables = vec![relative_records(num_measurements, &logical_z_refs)]; + + circuit.set_meta( + "num_measurements", + Attribute::String(num_measurements.to_string()), + ); + circuit.set_meta("detectors", Attribute::String(records_json(&detectors))); + circuit.set_meta("observables", Attribute::String(records_json(&observables))); + + Ok(MemoryCircuit { + circuit, + num_detectors: detectors.len(), + num_observables: observables.len(), + }) +} + +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("measurement record index fits in i32") + - num_measurements + }) + .collect() +} + +fn records_json(records: &[Vec]) -> String { + let entries: Vec = records + .iter() + .map(|rs| { + let values = rs.iter().map(i32::to_string).collect::>().join(","); + format!(r#"{{"records":[{values}]}}"#) + }) + .collect(); + format!("[{}]", entries.join(",")) +} + +#[cfg(test)] +mod tests { + use super::*; + use pecos_qec::fault_tolerance::targeted_lookup_decoder::TargetedLookupDecoder; + use std::collections::BTreeSet; + + #[test] + fn accumulates_repeated_logical_weights_for_a_syndrome() { + let mut weights = LookupWeights::new(); + + add_lookup_weight(&mut weights, vec![1, 3], vec![0], 0.125); + add_lookup_weight(&mut weights, vec![1, 3], vec![0], 0.375); + add_lookup_weight(&mut weights, vec![1, 3], Vec::new(), 0.250); + + assert_close(weights[&vec![1, 3]][&vec![0]], 0.500); + assert_close(weights[&vec![1, 3]][&Vec::new()], 0.250); + } + + #[test] + fn decoder_picks_most_likely_logical_per_syndrome() { + let mut weights = LookupWeights::new(); + + add_lookup_weight(&mut weights, Vec::new(), Vec::new(), 0.900); + add_lookup_weight(&mut weights, Vec::new(), vec![0], 0.100); + add_lookup_weight(&mut weights, vec![2], Vec::new(), 0.200); + add_lookup_weight(&mut weights, vec![2], vec![0], 0.700); + + let decoder = choose_most_likely_logicals(&weights); + + assert_eq!(decoder[&Vec::new()], Vec::::new()); + assert_eq!(decoder[&vec![2]], vec![0]); + } + + #[test] + fn small_catalog_lookup_matches_hand_calculation() { + let mut circuit = TickCircuit::new(); + circuit.tick().h(&[0]); + circuit.tick().mz(&[0]); + circuit.set_meta("num_measurements", Attribute::String("1".to_string())); + circuit.set_meta( + "detectors", + Attribute::String(r#"[{"records":[-1]}]"#.to_string()), + ); + circuit.set_meta( + "observables", + Attribute::String(r#"[{"records":[-1]}]"#.to_string()), + ); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&circuit, &noise).unwrap(); + let (weights, counts) = build_lookup_weights(&catalog, 1); + let decoder = choose_most_likely_logicals(&weights); + + assert_eq!(counts, vec![(0, 1), (1, 4)]); + + // k=0 no fault: 0.97 * 0.99 = 0.9603. + // k=1 no-effect H alternative: (0.03 / 3) * 0.99 = 0.0099. + assert_close(weights[&Vec::new()][&Vec::new()], 0.9702); + + // k=1 detector+logical events: + // two H alternatives flip MZ: 2 * (0.03 / 3) * 0.99 = 0.0198. + // one MZ flip: 0.01 * 0.97 = 0.0097. + assert_close(weights[&vec![0]][&vec![0]], 0.0295); + + assert_eq!(decoder[&Vec::new()], Vec::::new()); + assert_eq!(decoder[&vec![0]], vec![0]); + } + + #[test] + fn small_catalog_decoder_corrects_every_truncated_event() { + let mut circuit = TickCircuit::new(); + circuit.tick().h(&[0]); + circuit.tick().mz(&[0]); + circuit.set_meta("num_measurements", Attribute::String("1".to_string())); + circuit.set_meta( + "detectors", + Attribute::String(r#"[{"records":[-1]}]"#.to_string()), + ); + circuit.set_meta( + "observables", + Attribute::String(r#"[{"records":[-1]}]"#.to_string()), + ); + + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }; + let catalog = build_fault_catalog(&circuit, &noise).unwrap(); + let (weights, _) = build_lookup_weights(&catalog, 1); + let decoder = choose_most_likely_logicals(&weights); + + let mut decoded = 0usize; + for k in 0..=1 { + for event in catalog.fault_configurations(k) { + let correction = decoder + .get(&event.affected_detectors) + .expect("decoder should cover every truncated syndrome"); + let residual = xor_parity(&event.affected_observables, correction); + assert!( + residual.is_empty(), + "failed to decode syndrome {:?}: event logical {:?}, correction {:?}", + event.affected_detectors, + event.affected_observables, + correction + ); + decoded += 1; + } + } + + assert_eq!(decoded, 5); + } + + #[test] + fn d3_surface_lookup_builds_nontrivial_weight_one_table() { + let memory = build_d3_z_memory_circuit(3).unwrap(); + let noise = StochasticNoiseParams { + p1: 0.0001, + p2: 0.001, + p_meas: 0.001, + p_prep: 0.001, + }; + let catalog = build_fault_catalog(&memory.circuit, &noise).unwrap(); + let (weights, counts) = build_lookup_weights(&catalog, 1); + let decoder = choose_most_likely_logicals(&weights); + + assert_eq!(memory.num_detectors, 24); + assert_eq!(memory.num_observables, 1); + assert_eq!(counts[0], (0, 1)); + assert_eq!(counts[1].0, 1); + assert!(counts[1].1 > 1_000); + assert!(weights.len() > 10); + assert_eq!(decoder[&Vec::new()], Vec::::new()); + } + + #[test] + fn d3_surface_weight_one_decoder_corrects_weight_one_events() { + let memory = build_d3_z_memory_circuit(3).unwrap(); + let noise = StochasticNoiseParams { + p1: 0.0001, + p2: 0.001, + p_meas: 0.001, + p_prep: 0.001, + }; + let catalog = build_fault_catalog(&memory.circuit, &noise).unwrap(); + let (weights, _) = build_lookup_weights(&catalog, 1); + let decoder = choose_most_likely_logicals(&weights); + + let mut checked = 0usize; + for k in 0..=1 { + for event in catalog.fault_configurations(k) { + let correction = decoder + .get(&event.affected_detectors) + .expect("decoder should cover every weight-one syndrome"); + let residual = xor_parity(&event.affected_observables, correction); + assert!( + residual.is_empty(), + "failed to decode syndrome {:?}: event logical {:?}, correction {:?}", + event.affected_detectors, + event.affected_observables, + correction + ); + checked += 1; + } + } + + assert_eq!( + checked, + 1 + catalog + .locations + .iter() + .map(|loc| loc.num_alternatives) + .sum::() + ); + } + + #[test] + fn targeted_decoder_matches_bruteforce_on_real_d3_surface_catalog() { + let memory = build_d3_z_memory_circuit(3).unwrap(); + let noise = StochasticNoiseParams { + p1: 0.0001, + p2: 0.001, + p_meas: 0.001, + p_prep: 0.001, + }; + let catalog = build_fault_catalog(&memory.circuit, &noise).unwrap(); + let decoder = TargetedLookupDecoder::new(&catalog).max_faults(1); + let base_probability = decoder.base_probability(); + + let mut syndromes = BTreeSet::new(); + syndromes.insert(Vec::new()); + for event in catalog.fault_configurations(1) { + if !event.affected_detectors.is_empty() { + syndromes.insert(event.affected_detectors); + } + if syndromes.len() >= 8 { + break; + } + } + assert!( + syndromes.len() >= 4, + "surface catalog should expose several non-empty weight-one syndromes" + ); + + for syndrome in syndromes { + let result = decoder.decode(&syndrome); + let expected = brute_force_odds_for_syndrome(&catalog, 1, &syndrome, base_probability); + assert_eq!(result.syndrome, syndrome); + assert_logical_weights_close(&result.logical_weights, &expected); + + let expected_best = expected + .iter() + .max_by(|(_, a), (_, b)| a.total_cmp(b)) + .map(|(logical, _)| logical.clone()) + .unwrap_or_default(); + assert_eq!(result.best_logical, expected_best); + } + } + + fn brute_force_odds_for_syndrome( + catalog: &FaultCatalog, + max_faults: usize, + syndrome: &[usize], + base_probability: f64, + ) -> LogicalWeights { + let mut weights = LogicalWeights::new(); + for k in 0..=max_faults { + for event in catalog.fault_configurations(k) { + if event.affected_detectors == syndrome { + let odds = event.configuration_probability / base_probability; + weights + .entry(event.affected_observables) + .and_modify(|w| *w += odds) + .or_insert(odds); + } + } + } + weights + } + + fn assert_logical_weights_close(actual: &LogicalWeights, expected: &LogicalWeights) { + assert_eq!( + actual.keys().collect::>(), + expected.keys().collect::>() + ); + for (logical, expected_weight) in expected { + let actual_weight = actual[logical]; + let scale = expected_weight.abs().max(1e-15); + assert!( + (actual_weight - expected_weight).abs() / scale < 1e-10, + "logical={logical:?}: expected {expected_weight:.12e}, got {actual_weight:.12e}" + ); + } + } + + fn assert_close(actual: f64, expected: f64) { + assert!( + (actual - expected).abs() < 1e-12, + "expected {expected}, got {actual}" + ); + } + + fn xor_parity(a: &[usize], b: &[usize]) -> Vec { + let mut out = std::collections::BTreeSet::new(); + for value in a.iter().chain(b.iter()) { + if !out.remove(value) { + out.insert(*value); + } + } + out.into_iter().collect() + } +} diff --git a/examples/surface/decoder_comparison.py b/examples/surface/decoder_comparison.py new file mode 100644 index 000000000..d1b9ea91d --- /dev/null +++ b/examples/surface/decoder_comparison.py @@ -0,0 +1,542 @@ +r"""Decoder comparison: same samples, multiple decoders, one table. + +Generates DEM samples once per (distance, error_rate, rounds) point and +decodes them with every requested decoder. Produces an HTML report with +comparison tables showing logical error rates and decode throughput. + +This is complementary to ``native_dem_threshold_sweep.py`` which produces +threshold curves. This script answers "which decoder is best at a given +operating point?" with a controlled experiment (identical samples). + +Example: + python examples/surface/decoder_comparison.py + + python examples/surface/decoder_comparison.py \\ + --distances 3 5 7 \\ + --error-rates 0.004 0.008 \\ + --shots 2000 \\ + --decoders pymatching tesseract bp_osd \\ + --open-html +""" + +from __future__ import annotations + +import argparse +import html +import json +import time +from dataclasses import asdict, dataclass +from pathlib import Path +from textwrap import dedent +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pecos.qec.surface import NoiseModel + + +@dataclass +class DecoderResult: + """Result for one decoder at one operating point.""" + + decoder: str + distance: int + basis: str + physical_error_rate: float + num_rounds: int + num_shots: int + num_errors: int + logical_error_rate: float + decode_seconds: float + shots_per_second: float + per_shot_median: float = 0.0 + per_shot_p99: float = 0.0 + per_shot_max: float = 0.0 + + +@dataclass +class ComparisonPoint: + """All decoder results for one (distance, p, rounds) point.""" + + distance: int + basis: str + physical_error_rate: float + num_rounds: int + num_shots: int + sample_seconds: float + results: list[DecoderResult] + + +def _build_sampler( + distance: int, + num_rounds: int, + noise: NoiseModel, + basis: str, + circuit_source: str, +) -> tuple: + """Build the native sampler and get DEM strings.""" + from pecos.qec.surface import SurfacePatch, build_native_sampler + from pecos.qec.surface.decode import SurfaceDecoder, generate_circuit_level_dem_from_builder + + patch = SurfacePatch.create(distance=distance) + sampler = build_native_sampler( + patch, + num_rounds, + noise, + basis=basis, + circuit_source=circuit_source, + ) + + # Decomposed DEM for MWPM decoders + dec = SurfaceDecoder( + patch, + num_rounds=num_rounds, + noise=noise, + decoder_type="pymatching", + use_circuit_level_dem=True, + circuit_level_dem_mode="native_decomposed", + circuit_level_dem_source=circuit_source, + ) + dem_decomp = dec.get_dem(basis.upper(), circuit_level=True) + dem_decomp = "\n".join(line for line in dem_decomp.split("\n") if not line.startswith("logical_observable")) + + # Full DEM for non-MWPM decoders + dem_full = generate_circuit_level_dem_from_builder( + patch, + num_rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source=circuit_source, + ) + dem_full = "\n".join(line for line in dem_full.split("\n") if not line.startswith("logical_observable")) + + return sampler, dem_decomp, dem_full + + +# Decoders that need decomposed (graphlike) DEMs +# MWPM decoders get decomposed DEMs. DemMatchingGraph handles fault-ID-aware +# merging with first-observable-wins (matching PyMatching's INDEPENDENT strategy). +_MWPM_DECODERS = { + "pymatching", + "pymatching_uncorrelated", + "fusion_blossom", + "fusion_blossom_serial", + "fusion_blossom_parallel", + "pecos_uf", + "pecos_uf_correlated", + "pecos_uf:balanced", + "pecos_uf:fast", + "pecos_uf:bp", + "windowed", +} + +# All supported decoders +_ALL_DECODERS = [ + "pymatching", + "pymatching_uncorrelated", + "fusion_blossom", + "fusion_blossom_serial", + "fusion_blossom_parallel", + "tesseract", + "mwpf", + "bp_osd", + "union_find", + "min_sum_bp", + "relay_bp", + "pecos_uf", + "pecos_uf:balanced", + "pecos_uf:bp", + "pecos_uf_correlated", # legacy alias for pecos_uf:balanced +] + +# Decoders where parallel decoding helps +_SLOW_DECODERS = {"tesseract", "mwpf", "bp_osd", "relay_bp"} + + +def _decoder_base_name(name: str) -> str: + """Extract base decoder name, stripping config suffix (e.g. 'mwpf:c=30' -> 'mwpf').""" + return name.split(":", maxsplit=1)[0] + + +def run_comparison( + *, + distances: list[int], + error_rates: list[float], + decoders: list[str], + basis: str, + shots: int, + seed: int, + circuit_source: str, + p1_scale: float, + p_meas_scale: float, + p_prep_scale: float, +) -> list[ComparisonPoint]: + """Run the full comparison and return results.""" + from pecos.qec.surface import NoiseModel + + points: list[ComparisonPoint] = [] + total_configs = len(distances) * len(error_rates) + config_idx = 0 + + for distance in distances: + num_rounds = 2 * distance + for p in error_rates: + config_idx += 1 + noise = NoiseModel( + p1=p * p1_scale, + p2=p, + p_meas=p * p_meas_scale, + p_prep=p * p_prep_scale, + ) + + print(f"[{config_idx}/{total_configs}] d={distance} p={p:.4g} r={num_rounds} ...") + + sampler, dem_decomp, dem_full = _build_sampler( + distance, + num_rounds, + noise, + basis, + circuit_source, + ) + + # Generate samples once + t0 = time.perf_counter() + sample_batch = sampler.sampler.generate_samples(shots, seed=seed + config_idx) + sample_seconds = time.perf_counter() - t0 + + results: list[DecoderResult] = [] + for decoder_name in decoders: + base = _decoder_base_name(decoder_name) + # Ensemble uses decomposed DEMs (all ensemble members are matching-graph decoders) + dem = dem_decomp if base in _MWPM_DECODERS or base == "ensemble" else dem_full + + if base in _SLOW_DECODERS: + stats = sample_batch.decode_stats_parallel(dem, decoder_name) + else: + stats = sample_batch.decode_stats(dem, decoder_name) + + results.append( + DecoderResult( + decoder=decoder_name, + distance=distance, + basis=basis.upper(), + physical_error_rate=p, + num_rounds=num_rounds, + num_shots=shots, + num_errors=stats.num_errors, + logical_error_rate=stats.logical_error_rate, + decode_seconds=stats.total_seconds, + shots_per_second=shots / stats.total_seconds if stats.total_seconds > 0 else float("inf"), + per_shot_median=stats.per_shot_median, + per_shot_p99=stats.per_shot_p99, + per_shot_max=stats.per_shot_max, + ), + ) + print( + f" {decoder_name:14s}: {stats.num_errors:>4d}/{shots} " + f"LER={stats.logical_error_rate:.4f} " + f"mean={stats.per_shot_mean:.1e}s " + f"median={stats.per_shot_median:.1e}s " + f"p99={stats.per_shot_p99:.1e}s " + f"max={stats.per_shot_max:.1e}s", + ) + + points.append( + ComparisonPoint( + distance=distance, + basis=basis.upper(), + physical_error_rate=p, + num_rounds=num_rounds, + num_shots=shots, + sample_seconds=sample_seconds, + results=results, + ), + ) + + return points + + +def write_json(path: Path, points: list[ComparisonPoint], config: dict) -> None: + """Write results as JSON.""" + data = { + "config": config, + "points": [asdict(p) for p in points], + } + path.write_text(json.dumps(data, indent=2)) + print(f"Wrote JSON to {path}") + + +def write_html(path: Path, points: list[ComparisonPoint], config: dict) -> None: + """Write an HTML report with comparison tables.""" + style = dedent(""" + :root { + color-scheme: light dark; + --bg: #f8fafc; --fg: #0f172a; + --hero-bg: linear-gradient(135deg, #e0f2fe, #f8fafc 55%, #dcfce7); + --card-bg: white; --card-border: #dbeafe; --card-shadow: rgba(15,23,42,0.05); + --meta-bg: rgba(255,255,255,0.82); + --muted: #475569; --link: #2563eb; + --table-stripe: #f1f5f9; --table-border: #e2e8f0; + } + [data-theme="dark"] { + --bg: #0f172a; --fg: #e2e8f0; + --hero-bg: linear-gradient(135deg, #1e293b, #0f172a 55%, #1a2e1a); + --card-bg: #1e293b; --card-border: #334155; --card-shadow: rgba(0,0,0,0.3); + --meta-bg: rgba(30,41,59,0.82); + --muted: #94a3b8; --link: #60a5fa; + --table-stripe: #0f172a; --table-border: #334155; + } + @media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0f172a; --fg: #e2e8f0; + --hero-bg: linear-gradient(135deg, #1e293b, #0f172a 55%, #1a2e1a); + --card-bg: #1e293b; --card-border: #334155; --card-shadow: rgba(0,0,0,0.3); + --meta-bg: rgba(30,41,59,0.82); + --muted: #94a3b8; --link: #60a5fa; + --table-stripe: #0f172a; --table-border: #334155; + } + } + body { + margin: 0; + font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, sans-serif; + background: var(--bg); color: var(--fg); + } + main { max-width: 1400px; margin: 0 auto; padding: 32px 24px 56px; } + h1, h2, h3, p { margin-top: 0; } + .theme-toggle { + position: fixed; top: 16px; right: 16px; z-index: 100; + background: var(--card-bg); border: 1px solid var(--card-border); + border-radius: 8px; padding: 6px 12px; cursor: pointer; + color: var(--fg); font-size: 0.85rem; font-weight: 600; + } + .theme-toggle:hover { opacity: 0.8; } + .hero { + background: var(--hero-bg); + border: 1px solid var(--card-border); + border-radius: 20px; + padding: 24px; + margin-bottom: 24px; + } + .meta { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 12px; + margin-top: 18px; + } + .meta-card { + background: var(--meta-bg); + border: 1px solid var(--card-border); + border-radius: 14px; + padding: 14px 16px; + } + .meta-card strong { + display: block; font-size: 0.82rem; text-transform: uppercase; + letter-spacing: 0.04em; color: var(--muted); margin-bottom: 6px; + } + .section { + background: var(--card-bg); + border: 1px solid var(--card-border); + border-radius: 18px; + padding: 20px 22px; + margin-top: 20px; + box-shadow: 0 10px 24px var(--card-shadow); + overflow-x: auto; + } + table { border-collapse: collapse; width: 100%; margin: 12px 0 0; } + th, td { padding: 10px 14px; text-align: right; border-bottom: 1px solid var(--table-border); } + th { + font-size: 0.82rem; text-transform: uppercase; + letter-spacing: 0.03em; color: var(--muted); border-bottom-width: 2px; + } + td:first-child, th:first-child { text-align: left; } + tr:nth-child(even) td { background: var(--table-stripe); } + code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + """).strip() + + def meta_card(label: str, value: str) -> str: + return f'
{html.escape(label)}{html.escape(value)}
' + + decoders = config.get("decoders", []) + p1s = config.get("p1_scale", 1 / 30) + pms = config.get("p_meas_scale", 1 / 3) + pps = config.get("p_prep_scale", 1 / 3) + noise_str = f"p1={p1s:.4g}*p, p2=p, p_meas={pms:.4g}*p, p_prep={pps:.4g}*p" + parts = [ + "", + '', + "", + ' ', + ' ', + " PECOS Decoder Comparison", + f" ", + "", + "", + '', + "
", + '
', + "

PECOS Decoder Comparison

", + "

Same samples decoded by multiple decoders. LER differences reflect " + "decoder quality, not sampling noise.

", + '
', + meta_card("Decoders", ", ".join(decoders)), + meta_card("Distances", ", ".join(str(d) for d in config.get("distances", []))), + meta_card("Error Rates", ", ".join(f"{p:.4g}" for p in config.get("error_rates", []))), + meta_card("Shots", str(config.get("shots", 0))), + meta_card("Basis", config.get("basis", "Z")), + meta_card("Noise Model", noise_str), + meta_card("Circuit Source", config.get("circuit_source", "traced_qis")), + "
", + "
", + ] + + # Group by (distance, p) + for point in points: + d = point.distance + p = point.physical_error_rate + r = point.num_rounds + parts.append('
') + parts.append(f"

d={d}, p={p:.4g}, rounds={r} ({point.num_shots} shots)

") + + parts.append(" ") + parts.append( + " " + "", + ) + for res in sorted(point.results, key=lambda r: r.logical_error_rate): + mean_s = res.decode_seconds / res.num_shots if res.num_shots > 0 else 0 + sps = f"{res.shots_per_second:.1e}" + ler = res.logical_error_rate + n = res.num_shots + z = 1.96 + if n > 0 and 0 < ler < 1: + denom = 1 + z * z / n + center = (ler + z * z / (2 * n)) / denom + half = z * (ler * (1 - ler) / n + z * z / (4 * n * n)) ** 0.5 / denom + ci_lo, ci_hi = max(0, center - half), min(1, center + half) + elif n > 0: + ci_lo, ci_hi = ler, ler + else: + ci_lo, ci_hi = 0, 0 + ler_str = f"{ler:.4f} ({ci_lo:.4f} - {ci_hi:.4f})" + parts.append( + f" " + f"" + f"" + f"" + f"" + f"" + f"" + f"" + f"", + ) + parts.append("
DecoderLER (95% CI)MeanMedianp99MaxThroughput
{html.escape(res.decoder)}{ler_str}{mean_s:.1e} s{res.per_shot_median:.1e} s{res.per_shot_p99:.1e} s{res.per_shot_max:.1e} s{sps} shots/s
") + parts.append("
") + + parts.extend( + [ + "
", + dedent(""" + + """).strip(), + "", + "", + ], + ) + + path.write_text("\n".join(parts)) + print(f"Wrote HTML to {path}") + + +def main() -> int: + """CLI entry point for decoder comparison.""" + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) + parser.add_argument("--error-rates", nargs="+", type=float, default=[0.004, 0.008]) + parser.add_argument("--shots", type=int, default=1000) + parser.add_argument("--basis", default="Z") + parser.add_argument("--seed", type=int, default=42) + parser.add_argument( + "--decoders", + nargs="+", + default=["pymatching", "tesseract", "bp_osd"], + help=f"Decoders to compare. Available: {', '.join(_ALL_DECODERS)}", + ) + parser.add_argument("--circuit-source", default="traced_qis", choices=["traced_qis", "abstract"]) + parser.add_argument("--p1-scale", type=float, default=1.0 / 30.0) + parser.add_argument("--p-meas-scale", type=float, default=1.0 / 3.0) + parser.add_argument("--p-prep-scale", type=float, default=1.0 / 3.0) + parser.add_argument("--output-dir", type=str, default=None) + parser.add_argument("--open-html", action="store_true") + args = parser.parse_args() + + config = { + "distances": sorted(args.distances), + "error_rates": sorted(args.error_rates), + "shots": args.shots, + "basis": args.basis.upper(), + "decoders": args.decoders, + "circuit_source": args.circuit_source, + "p1_scale": args.p1_scale, + "p_meas_scale": args.p_meas_scale, + "p_prep_scale": args.p_prep_scale, + } + + print("PECOS Decoder Comparison") + print("=" * 40) + for k, v in config.items(): + print(f" {k}: {v}") + print() + + t0 = time.perf_counter() + points = run_comparison( + distances=sorted(args.distances), + error_rates=sorted(args.error_rates), + decoders=args.decoders, + basis=args.basis, + shots=args.shots, + seed=args.seed, + circuit_source=args.circuit_source, + p1_scale=args.p1_scale, + p_meas_scale=args.p_meas_scale, + p_prep_scale=args.p_prep_scale, + ) + elapsed = time.perf_counter() - t0 + print(f"\nTotal time: {elapsed:.1f}s") + + if args.output_dir: + out = Path(args.output_dir) + else: + import tempfile + + out = Path(tempfile.mkdtemp(prefix="pecos_decoder_comparison_")) + + out.mkdir(parents=True, exist_ok=True) + write_json(out / "decoder_comparison.json", points, config) + html_path = out / "decoder_comparison.html" + write_html(html_path, points, config) + + if args.open_html: + import webbrowser + + webbrowser.open(html_path.as_uri()) + print(f"Opened {html_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/surface/dem_comparison.py b/examples/surface/dem_comparison.py new file mode 100644 index 000000000..d9b178981 --- /dev/null +++ b/examples/surface/dem_comparison.py @@ -0,0 +1,203 @@ +r"""Compare ALL DEM generation methods against simulation ground truth. + +Compares per-detector firing rates from: + 1. Non-EEG DemBuilder (backward Pauli propagation) + 2. DemSampler.from_circuit (separate DEM path) + 3. Backward Heisenberg EEG (exact for coherent, handles depol via attenuation) + 4. stabilizer() simulation (SparseStab, exact for depol, any distance) + 5. statevec() simulation (exact for everything, limited to small circuits) + +Example: + uv run python examples/surface/dem_comparison.py + uv run python examples/surface/dem_comparison.py -d 2 3 --p 0.005 --shots 20000 + uv run python examples/surface/dem_comparison.py -d 5 --no-statevec +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python" / "quantum-pecos" / "src")) + + +def run(*, distance, rounds, basis, p, shots, seed, run_statevec): + from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch + from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder, DemSampler + from pecos_rslib_exp import ( + depolarizing, + exact_detection_rates, + sim_neo, + stabilizer, + statevec, + ) + + patch = SurfacePatch.create(distance=distance) + b = LogicalCircuitBuilder() + b.add_patch(patch, "Q0") + b.add_memory("Q0", rounds=rounds, basis=basis) + tc = b.to_tick_circuit() + dag = tc.to_dag_circuit() + + det_json = json.loads(tc.get_meta("detectors")) + num_meas = int(tc.get_meta("num_measurements")) + num_dets = len(det_json) + + print(f"\n{'='*80}") + print(f"d={distance} {basis}-basis, {rounds} rounds, {num_dets} dets, p={p} (depol only)") + print(f"{'='*80}") + + def extract_det_rates(results): + rates = [0.0] * num_dets + for r in results: + meas = list(r) + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + rates[i] += 1.0 / len(results) + return rates + + # 1. Non-EEG DemBuilder analytical marginals + t0 = time.perf_counter() + analyzer = DagFaultAnalyzer(dag) + influence = analyzer.build_influence_map() + dem_obj = ( + DemBuilder(influence) + .with_noise(p1=p, p2=p, p_meas=p, p_prep=p) + .with_detectors_json(tc.get_meta("detectors")) + .with_observables_json(tc.get_meta("observables")) + .with_num_measurements(num_meas) + .build() + ) + dem_str = dem_obj.to_string() + dem_analytical = [0.0] * num_dets + for line in dem_str.strip().split("\n"): + if line.startswith("error("): + prob = float(line.split("(")[1].split(")")[0]) + for x in line.split(): + if x.startswith("D"): + d_id = int(x[1:]) + if d_id < num_dets: + dem_analytical[d_id] += prob + dem_build_time = time.perf_counter() - t0 + + # 2. DemSampler.from_circuit + t0 = time.perf_counter() + sampler_fc = DemSampler.from_circuit(dag, p1=p, p2=p, p_meas=p, p_prep=p) + batch_fc = sampler_fc.generate_samples(num_shots=shots, seed=seed) + dem_fc = [0.0] * num_dets + for i in range(shots): + syn = batch_fc.get_syndrome(i) + for dd in range(min(num_dets, len(syn))): + if syn[dd]: + dem_fc[dd] += 1.0 / shots + fc_time = time.perf_counter() - t0 + + # 3. Backward Heisenberg + t0 = time.perf_counter() + heis_results = exact_detection_rates(tc, p1=p, p2=p, p_meas=p, p_prep=p) + heis = [0.0] * num_dets + for det_id, prob in heis_results: + if det_id < num_dets: + heis[det_id] = prob + heis_time = time.perf_counter() - t0 + + # 4. stabilizer() ground truth + t0 = time.perf_counter() + noise = depolarizing().p1(p).p2(p).p_meas(p).p_prep(p) + stab_results = sim_neo(tc).quantum(stabilizer()).noise(noise).shots(shots).seed(seed).run() + stab = extract_det_rates(stab_results) + stab_time = time.perf_counter() - t0 + + # 5. statevec() (optional, small circuits only) + sv = None + sv_time = 0 + if run_statevec: + t0 = time.perf_counter() + sv_results = sim_neo(tc).quantum(statevec()).noise(noise).shots(shots).seed(seed + 1).run() + sv = extract_det_rates(sv_results) + sv_time = time.perf_counter() - t0 + + # Print timing + print( + f" DemBuilder: {dem_build_time*1000:.0f}ms, FromCircuit: {fc_time:.2f}s," + f" Heisenberg: {heis_time*1000:.0f}ms, Stabilizer: {stab_time:.2f}s" + + (f", StateVec: {sv_time:.1f}s" if sv else ""), + ) + + # Header + cols = ["Det", "DemBuild", "FromCirc", "Heisen", "Stabiliz"] + if sv: + cols.append("StateVec") + cols += ["DB/Stab", "FC/Stab", "H/Stab"] + hdr = f" {cols[0]:>4}" + for c in cols[1:]: + hdr += f" {c:>10}" + print(hdr) + + for dd in range(num_dets): + s = stab[dd] + if s < 0.001: + continue + + math.sqrt(s * (1 - s) / shots) + r_db = dem_analytical[dd] / s + r_fc = dem_fc[dd] / s + r_h = heis[dd] / s + + line = f" D{dd:>2} {dem_analytical[dd]:>10.6f} {dem_fc[dd]:>10.6f} {heis[dd]:>10.6f} {s:>10.6f}" + if sv: + line += f" {sv[dd]:>10.6f}" + line += f" {r_db:>10.3f} {r_fc:>10.3f} {r_h:>10.3f}" + + flags = [] + if abs(1 - r_db) > 0.15: + flags.append("DB") + if abs(1 - r_fc) > 0.15: + flags.append("FC") + if abs(1 - r_h) > 0.15: + flags.append("H") + if flags: + line += f" *** {','.join(flags)}" + print(line) + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--distance", "-d", type=int, nargs="+", default=[2, 3]) + parser.add_argument("--rounds", type=int, default=None) + parser.add_argument("--basis", choices=["X", "Z"], nargs="+", default=["Z"]) + parser.add_argument("--p", type=float, nargs="+", default=[0.005]) + parser.add_argument("--shots", type=int, default=10000) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--no-statevec", action="store_true") + args = parser.parse_args() + + for dist in args.distance: + for basis in args.basis: + for p_val in args.p: + rds = args.rounds if args.rounds is not None else dist + run( + distance=dist, + rounds=rds, + basis=basis, + p=p_val, + shots=args.shots, + seed=args.seed, + run_statevec=not args.no_statevec and dist <= 3, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/surface/dem_method_ler_comparison.py b/examples/surface/dem_method_ler_comparison.py new file mode 100644 index 000000000..84dd7ef28 --- /dev/null +++ b/examples/surface/dem_method_ler_comparison.py @@ -0,0 +1,456 @@ +r"""DEM method x decoder LER comparison on traced_qis circuits. + +Generates a traced_qis surface code circuit, builds DEMs from multiple +methods, samples once, and decodes with multiple decoders. Reports LER +for each (DEM method, decoder) combination. + +DEM methods: + 1. from_circuit — non-EEG backward Pauli propagation (stochastic only) + 2. coherent_exact — EEG backward Heisenberg + L-BFGS fit + 3. noise_char — EEG unified (correlations + mechanisms + DEM) + 4. perturbative — EEG forward pass (fast, approximate) + +Each method produces a raw DEM string. MWPM decoders (pymatching, +fusion_blossom) use the standard decomposed DEM from ``from_circuit`` +since they cannot handle hyperedges. Non-MWPM decoders (tesseract, +bp_osd) use the raw DEM from each method. + +Example: + uv run python examples/surface/dem_method_ler_comparison.py + uv run python examples/surface/dem_method_ler_comparison.py \ + --distances 3 5 --shots 50000 --decoders pymatching tesseract +""" + +from __future__ import annotations + +import argparse +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python" / "quantum-pecos" / "src")) + +# Slow decoders that benefit from parallel decoding +SLOW_DECODERS = {"tesseract", "bp_osd"} + + +def _decoder_requires_graphlike(decoder: str) -> bool: + """Check if a decoder requires graphlike (decomposed) DEMs.""" + from pecos_rslib.qec import decoder_dem_requirement + + base = decoder.split(":", maxsplit=1)[0] + return decoder_dem_requirement(base) == "graphlike" + + +def sim_results_to_sample_batch(results, det_json, obs_json, num_meas): + """Convert sim_neo() results to a SampleBatch. + + Computes detection events from detector record XOR definitions, + and observable flips from observable record XOR definitions. + """ + import json + + from pecos_rslib.qec import SampleBatch + + dets = det_json if isinstance(det_json, list) else json.loads(det_json) + obs = obs_json if isinstance(obs_json, list) else json.loads(obs_json) + num_dets = len(dets) + len(obs) + + detection_events = [] + observable_masks = [] + + for r in results: + meas = list(r) + + # Detection events: XOR of records per detector + syn = [0] * num_dets + for i, det in enumerate(dets): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + syn[i] = val + detection_events.append(syn) + + # Observable flips: XOR of records per observable + obs_mask = 0 + for j, ob in enumerate(obs): + val = 0 + for rec in ob["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + obs_mask |= 1 << j + observable_masks.append(obs_mask) + + return SampleBatch(detection_events, observable_masks) + + +def build_tick_circuits(distance: int, num_rounds: int, basis: str): + """Build both abstract and traced_qis TickCircuits for a surface code. + + Returns (patch, abstract_tc, traced_tc). + EEG methods need the abstract circuit (CX/H gate set). + Non-EEG DemBuilder uses the traced circuit (physical gates). + """ + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=distance) + abstract_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds, + basis, + circuit_source="abstract", + ) + traced_tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds, + basis, + circuit_source="traced_qis", + ) + return patch, abstract_tc, traced_tc + + +def generate_dems( + abstract_tc, + _traced_tc, + patch, + num_rounds, + noise_params: dict, + basis: str, +) -> list[tuple[str, str, str | None]]: + """Generate DEM strings from all methods. + + EEG methods use the abstract circuit (CX/H gate set). + Non-EEG DemBuilder uses the traced circuit (physical Selene gates). + + Returns list of (method_name, raw_dem, decomposed_dem_or_None). + decomposed_dem is None when the method cannot produce a graphlike DEM. + """ + from pecos.qec.surface import NoiseModel + from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder + + results = [] + + noise = NoiseModel( + p1=noise_params.get("p1", 0.0), + p2=noise_params.get("p2", 0.0), + p_meas=noise_params.get("p_meas", 0.0), + p_prep=noise_params.get("p_prep", 0.0), + ) + + # 1. from_circuit on traced (non-EEG, stochastic, physical gates) + try: + raw = generate_circuit_level_dem_from_builder( + patch, + num_rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source="traced_qis", + ) + decomp = generate_circuit_level_dem_from_builder( + patch, + num_rounds, + noise, + basis=basis, + decompose_errors=True, + circuit_source="traced_qis", + ) + results.append(("from_circuit_traced", raw, decomp)) + except Exception as e: + print(f" WARN: from_circuit_traced failed: {e}") + + # 1b. from_circuit on abstract (non-EEG, stochastic, logical gates) + try: + raw = generate_circuit_level_dem_from_builder( + patch, + num_rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source="abstract", + ) + decomp = generate_circuit_level_dem_from_builder( + patch, + num_rounds, + noise, + basis=basis, + decompose_errors=True, + circuit_source="abstract", + ) + results.append(("from_circuit_abstract", raw, decomp)) + except Exception as e: + print(f" WARN: from_circuit_abstract failed: {e}") + + # EEG methods use the abstract circuit (CX/H gate set) + # 2. coherent_dem_decomposed (EEG, X/Z Pauli-aware decomposition) + try: + from pecos_rslib_exp import coherent_dem_decomposed + + raw_dem, decomp_dem = coherent_dem_decomposed(abstract_tc, **noise_params) + if raw_dem.strip(): + results.append(("coherent_decomp", raw_dem, decomp_dem)) + except Exception as e: + print(f" WARN: coherent_dem_decomposed failed: {e}") + + # 3. noise_characterization (EEG, unified) + try: + from pecos_rslib_exp import noise_characterization + + _json_str, dem_raw, dem_decomp = noise_characterization(abstract_tc, **noise_params) + if dem_raw.strip(): + results.append(("noise_char", dem_raw, dem_decomp)) + except Exception as e: + print(f" WARN: noise_characterization failed: {e}") + + # 4. perturbative_dem (EEG, forward) + try: + from pecos_rslib_exp import perturbative_dem + + dem_raw, dem_decomp = perturbative_dem(abstract_tc, **noise_params) + if dem_raw.strip(): + results.append(("perturbative", dem_raw, dem_decomp)) + except Exception as e: + print(f" WARN: perturbative_dem failed: {e}") + + return results + + +def _sample_from_sim(tc, noise_params, shots, seed, backend="statevec"): + """Sample using sim_neo (captures actual noise including coherent). + + backend: "statevec" (exact, small circuits), + "stabilizer" (exact for depol, fast — no coherent idle_rz), + "stab_mps" (handles coherent noise, any distance, approximate). + """ + import json + + from pecos_rslib_exp import depolarizing, sim_neo, stab_mps, stabilizer, statevec + + p1 = noise_params.get("p1", 0.0) + p2 = noise_params.get("p2", 0.0) + p_meas = noise_params.get("p_meas", 0.0) + p_prep = noise_params.get("p_prep", 0.0) + irz = noise_params.get("idle_rz", 0.0) + + noise = depolarizing().p1(p1).p2(p2).p_meas(p_meas).p_prep(p_prep) + if irz > 0: + noise = noise.idle_rz(irz) + + if backend == "stabilizer": + quantum_backend = stabilizer() + elif backend == "stab_mps": + quantum_backend = stab_mps() + else: + quantum_backend = statevec() + + results = sim_neo(tc).quantum(quantum_backend).noise(noise).shots(shots).seed(seed).run() + + det_json = json.loads(tc.get_meta("detectors")) + obs_json = json.loads(tc.get_meta("observables")) + num_meas = int(tc.get_meta("num_measurements")) + + return sim_results_to_sample_batch(results, det_json, obs_json, num_meas) + + +def strip_logical_observable_lines(dem_str: str) -> str: + """Remove logical_observable lines that some decoders choke on.""" + return "\n".join(line for line in dem_str.split("\n") if not line.startswith("logical_observable")) + + +def run_comparison( + *, + distances: list[int], + noise_configs: list[tuple[str, dict]], + decoders: list[str], + basis: str, + shots: int, + seed: int, + sample_backend: str, +): + """Run the full DEM method x decoder comparison.""" + all_results = [] + + for distance in distances: + num_rounds = 2 * distance + for noise_label, noise_params in noise_configs: + print(f"\n{'='*72}") + print(f"d={distance} {basis}-basis, {num_rounds} rounds, {noise_label}") + print(f" sample_backend={sample_backend}") + print(f"{'='*72}") + + # Build both abstract and traced circuits + t0 = time.perf_counter() + patch, abstract_tc, traced_tc = build_tick_circuits(distance, num_rounds, basis) + t_circuit = time.perf_counter() - t0 + print(f" Circuits built in {t_circuit:.2f}s") + + sampler_params = {k: v for k, v in noise_params.items() if k in ("p1", "p2", "p_meas", "p_prep")} + idle_rz = noise_params.get("idle_rz", 0.0) + + # Generate samples + t0 = time.perf_counter() + if sample_backend in ("statevec", "stabilizer", "stab_mps"): + # Simulator-based sampling + batch = _sample_from_sim( + abstract_tc, + noise_params, + shots, + seed, + backend=sample_backend, + ) + else: + # DemSampler: fast, stochastic-only sampling + from pecos_rslib.qec import DemSampler + + sampler = DemSampler.from_circuit( + traced_tc, + **sampler_params, + idle_rz=idle_rz if idle_rz > 0 else None, + ) + batch = sampler.generate_samples(shots, seed=seed) + t_sample = time.perf_counter() - t0 + print(f" Sampled {shots} shots in {t_sample:.2f}s") + + # Generate DEMs from all methods + t0 = time.perf_counter() + dems = generate_dems(abstract_tc, traced_tc, patch, num_rounds, noise_params, basis) + t_dems = time.perf_counter() - t0 + print(f" Generated {len(dems)} DEMs in {t_dems:.2f}s") + for name, dem_str, _decomp in dems: + n_lines = len([line for line in dem_str.strip().split("\n") if line.strip()]) + print(f" {name}: {n_lines} lines") + + # Build column headers: for raw-capable decoders, show both raw and decomposed + columns = [] + for dec in decoders: + if _decoder_requires_graphlike(dec): + columns.append((dec, "decomp")) + else: + columns.append((dec, "raw")) + columns.append((dec, "decomp")) + + # Print header + print(f"\n {'DEM Method':<22s}", end="") + for dec, dem_type in columns: + label = f"{dec}({dem_type[0]})" if dem_type == "raw" else f"{dec}(d)" + print(f" | {label:>16s}", end="") + print() + print(f" {'-'*22}", end="") + for _ in columns: + print(f" | {'-'*16}", end="") + print() + + for dem_name, dem_raw, dem_decomp in dems: + dem_raw_clean = strip_logical_observable_lines(dem_raw) + dem_decomp_clean = strip_logical_observable_lines(dem_decomp) if dem_decomp else None + print(f" {dem_name:<22s}", end="", flush=True) + + for decoder, dem_type in columns: + base = decoder.split(":")[0] + + if dem_type == "decomp" and dem_decomp_clean is None: + # No decomposed DEM available for this method + print(f" | {'N/A':>16s}", end="") + continue + + dem = dem_raw_clean if dem_type == "raw" else dem_decomp_clean + + try: + if base in SLOW_DECODERS: + stats = batch.decode_stats_parallel(dem, decoder) + else: + stats = batch.decode_stats(dem, decoder) + ler = stats.logical_error_rate + print(f" | {ler:>16.4f}", end="") + all_results.append( + { + "distance": distance, + "noise": noise_label, + "dem_method": dem_name, + "decoder": decoder, + "dem_type": dem_type, + "num_shots": shots, + "num_errors": stats.num_errors, + "ler": ler, + "decode_s": stats.total_seconds, + }, + ) + except Exception: + # Decoder can't handle this DEM (e.g., hyperedges in graphlike DEM) + print(f" | {'N/A':>16s}", end="") + + print() + + return all_results + + +def main(): + parser = argparse.ArgumentParser( + description="DEM method x decoder LER comparison on traced_qis circuits", + ) + parser.add_argument("--distances", type=int, nargs="+", default=[3, 5]) + parser.add_argument("--shots", type=int, default=10_000) + parser.add_argument("--basis", default="Z") + parser.add_argument("--seed", type=int, default=42) + parser.add_argument( + "--decoders", + nargs="+", + default=["pymatching", "fusion_blossom", "tesseract"], + ) + parser.add_argument("--p2", type=float, default=0.005) + parser.add_argument("--idle-rz", type=float, default=0.05) + parser.add_argument( + "--noise", + nargs="+", + default=["depol", "depol+irz"], + choices=["depol", "depol+irz"], + ) + parser.add_argument( + "--sample-backend", + default="native", + choices=["native", "statevec", "stabilizer", "stab_mps"], + help="'native' uses DemSampler (fast, stochastic). " + "'statevec' uses exact state vector sim (slow, captures coherent). " + "'stabilizer' uses stabilizer sim (fast, exact for depolarizing). " + "'stab_mps' uses tensor network sim (handles coherent, any distance).", + ) + args = parser.parse_args() + + p2 = args.p2 + noise_configs = [] + if "depol" in args.noise: + noise_configs.append( + ( + f"depol(p2={p2})", + {"p1": p2 / 10, "p2": p2, "p_meas": p2, "p_prep": p2, "idle_rz": 0.0}, + ), + ) + if "depol+irz" in args.noise: + noise_configs.append( + ( + f"depol+irz(p2={p2},irz={args.idle_rz})", + {"p1": p2 / 10, "p2": p2, "p_meas": p2, "p_prep": p2, "idle_rz": args.idle_rz}, + ), + ) + + results = run_comparison( + distances=args.distances, + noise_configs=noise_configs, + decoders=args.decoders, + basis=args.basis, + shots=args.shots, + seed=args.seed, + sample_backend=args.sample_backend, + ) + + print(f"\n\nTotal results: {len(results)}") + + +if __name__ == "__main__": + main() diff --git a/examples/surface/dem_tutorial.py b/examples/surface/dem_tutorial.py new file mode 100644 index 000000000..545d66f18 --- /dev/null +++ b/examples/surface/dem_tutorial.py @@ -0,0 +1,132 @@ +"""DEM generation tutorial: build circuit, inspect DEM, sample, validate. + +Demonstrates the full PECOS DEM pipeline using a d=3 surface code. + +Usage: + uv run python examples/surface/dem_tutorial.py +""" + +from __future__ import annotations + +import json +import sys +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python" / "quantum-pecos" / "src")) + +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib.qec import DemSampler, DetectorErrorModel +from pecos_rslib_exp import depolarizing, sim_neo, stabilizer + + +def main(): + # ================================================================ + # 1. Build a surface code circuit + # ================================================================ + distance = 3 + patch = SurfacePatch.create(distance=distance) + b = LogicalCircuitBuilder() + b.add_patch(patch, "Q0") + b.add_memory("Q0", rounds=distance, basis="Z") + tc = b.to_tick_circuit() + + print(f"Surface code d={distance}, {tc.num_ticks()} ticks") + print( + f" {int(tc.get_meta('num_measurements'))} measurements, " + f"{len(json.loads(tc.get_meta('detectors')))} detectors", + ) + + # ================================================================ + # 2. Inspect measurement IDs on gates + # ================================================================ + # Each MZ gate carries a MeasId — a stable identity for that + # measurement result. These persist through all transformations. + dag = tc.to_dag_circuit() + + print("\nMeasurement gates (first 5):") + shown = 0 + for node_id in dag.nodes(): + gate = dag.gate(node_id) + if gate and gate.gate_type.name == "MZ" and shown < 5: + print(f" node={node_id}: MZ qubit={list(gate.qubits)} meas_ids={gate.meas_ids}") + shown += 1 + + # ================================================================ + # 3. Inspect detector definitions + # ================================================================ + # Detectors reference measurements by both: + # - "records": negative offsets (Stim compatibility) + # - "meas_ids": stable MeasId values (preferred) + dets = json.loads(tc.get_meta("detectors")) + print(f"\nDetector definitions (first 3 of {len(dets)}):") + for det in dets[:3]: + print(f" D{det['id']}: meas_ids={det['meas_ids']} records={det['records']}") + + # ================================================================ + # 4. Build and inspect the DEM (one line) + # ================================================================ + p = 0.005 + dem = DetectorErrorModel.from_circuit(tc, p1=p, p2=p, p_meas=p, p_prep=p) + print(f"\nDetectorErrorModel: {dem.num_detectors} detectors, {dem.num_observables} observables") + + dem_str = dem.to_string() + error_lines = [line for line in dem_str.split("\n") if line.startswith("error(")] + print(f" {len(error_lines)} DEM events") + print(" First 3 events:") + for line in error_lines[:3]: + print(f" {line}") + + # ================================================================ + # 5. Sample from the DEM (one line) + # ================================================================ + shots = 100_000 + sampler = DemSampler.from_circuit(tc, p1=p, p2=p, p_meas=p, p_prep=p) + batch = sampler.generate_samples(num_shots=shots, seed=42) + + # Compute per-detector firing rates from DEM sampling + num_dets = len(dets) + dem_rates = [0.0] * num_dets + for i in range(shots): + syn = batch.get_syndrome(i) + for d in range(min(num_dets, len(syn))): + if syn[d]: + dem_rates[d] += 1.0 / shots + + # ================================================================ + # 6. Validate against stabilizer simulation (ground truth) + # ================================================================ + noise = depolarizing().p1(p).p2(p).p_meas(p).p_prep(p) + results = sim_neo(tc).quantum(stabilizer()).noise(noise).shots(shots).seed(42).run() + + num_meas = int(tc.get_meta("num_measurements")) + sim_rates = [0.0] * num_dets + for r in results: + meas = list(r) + for i, det in enumerate(dets): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + sim_rates[i] += 1.0 / shots + + # ================================================================ + # 7. Compare + # ================================================================ + print(f"\nPer-detector rates (p={p}, {shots} shots):") + print(f" {'Det':>4} {'DEM':>10} {'Stabilizer':>10} {'Ratio':>7}") + max_rel = 0 + for d in range(num_dets): + if sim_rates[d] > 0.003: + ratio = dem_rates[d] / sim_rates[d] + max_rel = max(max_rel, abs(1 - ratio)) + print(f" D{d:>2} {dem_rates[d]:>10.5f} {sim_rates[d]:>10.5f} {ratio:>7.3f}") + + status = "PASS" if max_rel < 0.15 else f"FAIL (max_rel={max_rel*100:.0f}%)" + print(f"\nValidation: {status}") + print(f" Max relative error: {max_rel*100:.1f}% (threshold: 15% for {shots} shots)") + + +if __name__ == "__main__": + main() diff --git a/examples/surface/dem_vs_stabilizer.py b/examples/surface/dem_vs_stabilizer.py new file mode 100644 index 000000000..5a48f938a --- /dev/null +++ b/examples/surface/dem_vs_stabilizer.py @@ -0,0 +1,163 @@ +r"""Compare non-EEG DEM detection rates against stabilizer() ground truth. + +Tests per-detector firing rates from: + 1. DemBuilder analytical marginals (sum of DEM event probabilities) + 2. DemSampler.from_circuit sampled rates + 3. stabilizer() simulation (SparseStab ground truth) + +Pure depolarizing noise only (no coherent idle_rz). + +Example: + uv run python examples/surface/dem_vs_stabilizer.py + uv run python examples/surface/dem_vs_stabilizer.py -d 2 3 --p 0.005 +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python" / "quantum-pecos" / "src")) + + +def run_comparison(*, distance, rounds, basis, p, shots, seed): + from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch + from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder, DemSampler + from pecos_rslib_exp import depolarizing, sim_neo, stabilizer + + patch = SurfacePatch.create(distance=distance) + b = LogicalCircuitBuilder() + b.add_patch(patch, "Q0") + b.add_memory("Q0", rounds=rounds, basis=basis) + tc = b.to_tick_circuit() + dag = tc.to_dag_circuit() + + det_json = json.loads(tc.get_meta("detectors")) + num_meas = int(tc.get_meta("num_measurements")) + num_dets = len(det_json) + + print(f"\n{'='*70}") + print(f"d={distance} {basis}-basis, {rounds} rounds, {num_dets} dets, p={p}") + print(f"{'='*70}") + + # 1. DemBuilder analytical marginals + analyzer = DagFaultAnalyzer(dag) + influence = analyzer.build_influence_map() + dem_obj = ( + DemBuilder(influence) + .with_noise(p1=p, p2=p, p_meas=p, p_prep=p) + .with_detectors_json(tc.get_meta("detectors")) + .with_observables_json(tc.get_meta("observables")) + .with_num_measurements(num_meas) + .build() + ) + dem_str = dem_obj.to_string() + analytical = [0.0] * num_dets + for line in dem_str.strip().split("\n"): + if line.startswith("error("): + prob = float(line.split("(")[1].split(")")[0]) + for x in line.split(): + if x.startswith("D"): + d_id = int(x[1:]) + if d_id < num_dets: + analytical[d_id] += prob + + # 2. DemSampler.from_circuit + t0 = time.perf_counter() + sampler_fc = DemSampler.from_circuit(dag, p1=p, p2=p, p_meas=p, p_prep=p) + batch_fc = sampler_fc.generate_samples(num_shots=shots, seed=seed) + dem_fc = [0.0] * num_dets + for i in range(shots): + syn = batch_fc.get_syndrome(i) + for dd in range(min(num_dets, len(syn))): + if syn[dd]: + dem_fc[dd] += 1.0 / shots + fc_time = time.perf_counter() - t0 + + # 3. stabilizer() ground truth + t0 = time.perf_counter() + noise = depolarizing().p1(p).p2(p).p_meas(p).p_prep(p) + results = sim_neo(tc).quantum(stabilizer()).noise(noise).shots(shots).seed(seed).run() + sim = [0.0] * num_dets + for r in results: + meas = list(r) + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + sim[i] += 1.0 / shots + sim_time = time.perf_counter() - t0 + + print(f" DEM sample: {fc_time:.2f}s, Stabilizer: {sim_time:.2f}s ({shots} shots)") + print( + f" {'Det':>4} {'Analytical':>11} {'FromCirc':>10} {'Stabiliz':>10}" + f" {'SV_se':>8} {'A/Sim':>7} {'FC/Sim':>7}", + ) + + max_a_err = 0.0 + max_fc_err = 0.0 + flagged = 0 + for dd in range(num_dets): + sv_r = sim[dd] + se = math.sqrt(sv_r * (1 - sv_r) / shots) if sv_r > 0 else 0 + + if sv_r > 0.002: + ra = analytical[dd] / sv_r + rf = dem_fc[dd] / sv_r + max_a_err = max(max_a_err, abs(1 - ra)) + max_fc_err = max(max_fc_err, abs(1 - rf)) + else: + ra = float("nan") + rf = float("nan") + + flag = "" + if sv_r > 0.002 and (abs(1 - ra) > 0.15 or abs(1 - rf) > 0.15): + flag = " ***" + flagged += 1 + + print( + f" D{dd:>2} {analytical[dd]:>11.6f} {dem_fc[dd]:>10.6f} {sv_r:>10.6f}" + f" {se:>8.5f} {ra:>7.3f} {rf:>7.3f}{flag}", + ) + + print( + f" Max deviation: Analytical={max_a_err*100:.1f}%, FromCircuit={max_fc_err*100:.1f}%, flagged={flagged}", + ) + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--distance", "-d", type=int, nargs="+", default=[2, 3]) + parser.add_argument("--rounds", type=int, default=None) + parser.add_argument("--basis", choices=["X", "Z"], nargs="+", default=["X", "Z"]) + parser.add_argument("--p", type=float, nargs="+", default=[0.005]) + parser.add_argument("--shots", type=int, default=10000) + parser.add_argument("--seed", type=int, default=42) + args = parser.parse_args() + + for dist in args.distance: + for basis in args.basis: + for p_val in args.p: + rds = args.rounds if args.rounds is not None else dist + run_comparison( + distance=dist, + rounds=rds, + basis=basis, + p=p_val, + shots=args.shots, + seed=args.seed, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/surface/eeg_formula_comparison.py b/examples/surface/eeg_formula_comparison.py new file mode 100644 index 000000000..6b32a607b --- /dev/null +++ b/examples/surface/eeg_formula_comparison.py @@ -0,0 +1,214 @@ +r"""Compare all forward EEG formula variants against backward Heisenberg. + +Tests 6 forward EEG configurations: + 1. Taylor + BCH1 (default) + 2. SinSquared + BCH1 + 3. ExactCommuting + BCH1 + 4. Taylor + BCH2 + 5. SinSquared + BCH2 + 6. ExactCommuting + BCH2 + +Against: + 7. Backward Heisenberg (exact) + 8. StateVec simulation (ground truth, optional) + +Example: + uv run python examples/surface/eeg_formula_comparison.py + uv run python examples/surface/eeg_formula_comparison.py -d 3 --theta 0.05 --shots 50000 + uv run python examples/surface/eeg_formula_comparison.py -d 2 3 --no-statevec +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python" / "quantum-pecos" / "src")) + +CONFIGS = [ + ("Taylor", "taylor", 1), + ("SinSq", "sin_squared", 1), + ("ExCom", "exact_commuting", 1), + ("ExSubset", "exact_subset", 1), +] + + +def marginals_from_events(events, num_dets): + rates = [0.0] * num_dets + for prob, det_ids, _obs_ids in events: + for d in det_ids: + if d < num_dets: + rates[d] += prob + return rates + + +def run(*, distance, rounds, basis, theta_values, shots, seed, run_statevec): + from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch + from pecos_rslib_exp import eeg_per_detector, exact_detection_rates, perturbative_dem_events + + patch = SurfacePatch.create(distance=distance) + b = LogicalCircuitBuilder() + b.add_patch(patch, "Q0") + b.add_memory("Q0", rounds=rounds, basis=basis) + tc = b.to_tick_circuit() + + det_json = json.loads(tc.get_meta("detectors")) + num_meas = int(tc.get_meta("num_measurements")) + num_dets = len(det_json) + + print(f"\n{'='*80}") + print(f"d={distance} {basis}-basis memory, {rounds} rounds, {num_dets} detectors") + print(f"{'='*80}") + + for theta in theta_values: + print(f"\ntheta = {theta:.4f}") + + # Forward EEG: all 6 configs + fwd_marginals = {} + for name, h_formula, bch in CONFIGS: + t0 = time.perf_counter() + events = perturbative_dem_events(tc, idle_rz=theta, h_formula=h_formula, bch_order=bch) + dt = time.perf_counter() - t0 + fwd_marginals[name] = (marginals_from_events(events, num_dets), dt) + + # Per-detector computation (cross-event beta) + per_det_marginals = {} + for name, h_formula, _ in [ + ("PD-Taylor", "taylor", 0), + ("PD-SinSq", "sin_squared", 0), + ("PD-ExCom", "exact_commuting", 0), + ]: + t0 = time.perf_counter() + pd_results = eeg_per_detector(tc, idle_rz=theta, h_formula=h_formula) + dt = time.perf_counter() - t0 + rates = [0.0] * num_dets + for det_id, prob in pd_results: + if det_id < num_dets: + rates[det_id] = prob + per_det_marginals[name] = (rates, dt) + + # Backward Heisenberg (exact) + t0 = time.perf_counter() + heis_results = exact_detection_rates(tc, idle_rz=theta) + heis_time = time.perf_counter() - t0 + heis = [0.0] * num_dets + for det_id, prob in heis_results: + if det_id < num_dets: + heis[det_id] = prob + + # StateVec (optional ground truth) + sv_rate = None + if run_statevec: + from pecos_rslib_exp import depolarizing, sim_neo, statevec + + t0 = time.perf_counter() + noise = depolarizing().idle_rz(theta) + results = sim_neo(tc).quantum(statevec()).noise(noise).shots(shots).seed(seed).run() + sv_time = time.perf_counter() - t0 + + sv_det_count = [0] * num_dets + for r in results: + meas = list(r) + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + sv_det_count[i] += 1 + sv_rate = [c / shots for c in sv_det_count] + + # Summary table: max relative error vs Heisenberg for each config + print(f"\n {'Config':<14} {'Time':>8} {'MaxRelErr':>10} {'MeanRelErr':>11} {'MaxAbsErr':>10}") + print(f" {'-'*14} {'-'*8} {'-'*10} {'-'*11} {'-'*10}") + + all_configs = [(name, fwd_marginals[name]) for name, _, _ in CONFIGS] + all_configs += [(name, per_det_marginals[name]) for name in ["PD-Taylor", "PD-SinSq", "PD-ExCom"]] + + for name, (rates, dt) in all_configs: + max_rel = 0.0 + sum_rel = 0.0 + max_abs = 0.0 + count = 0 + for d in range(num_dets): + if heis[d] > 1e-6: + rel = abs(rates[d] - heis[d]) / heis[d] + max_rel = max(max_rel, rel) + sum_rel += rel + count += 1 + max_abs = max(max_abs, abs(rates[d] - heis[d])) + mean_rel = sum_rel / count if count > 0 else 0 + print(f" {name:<14} {dt*1000:>7.1f}ms {max_rel*100:>9.1f}% {mean_rel*100:>10.1f}% {max_abs:>10.6f}") + + print(f" {'Heisenberg':<14} {heis_time*1000:>7.1f}ms {'(exact)':>10}") + + if sv_rate is not None: + # Also compare Heisenberg vs StateVec + max_rel_h = 0.0 + for d in range(num_dets): + if sv_rate[d] > 0.01: + max_rel_h = max(max_rel_h, abs(heis[d] - sv_rate[d]) / sv_rate[d]) + print(f" {'StateVec':<14} {sv_time:>6.1f}s H/SV max err: {max_rel_h*100:.1f}%") + + # Per-detector detail (compact — only show non-zero detectors, skip redundant DEM configs) + if num_dets <= 40: + # Show: Heisenberg, Taylor (DEM), PD-Taylor, PD-ExCom, SV + show_configs = [ + ("Taylor", lambda fwd_marginals=fwd_marginals: fwd_marginals["Taylor"]), + ("ExSubset", lambda fwd_marginals=fwd_marginals: fwd_marginals["ExSubset"]), + ("PD-Tayl", lambda per_det_marginals=per_det_marginals: per_det_marginals["PD-Taylor"]), + ] + cols = ["Det", "Heisen"] + [n for n, _ in show_configs] + if sv_rate is not None: + cols.append("SV") + header = f" {cols[0]:>4} {cols[1]:>10}" + for c in cols[2:]: + header += f" {c:>10}" + print(f"\n{header}") + + for d in range(num_dets): + if heis[d] < 1e-8 and all(fn()[0][d] < 1e-8 for _, fn in show_configs): + continue + line = f" D{d:>2} {heis[d]:>10.6f}" + for _, fn in show_configs: + rates, _ = fn() + line += f" {rates[d]:>10.6f}" + if sv_rate is not None: + line += f" {sv_rate[d]:>10.6f}" + print(line) + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--distance", "-d", type=int, nargs="+", default=[2, 3]) + parser.add_argument("--rounds", type=int, default=None) + parser.add_argument("--basis", choices=["X", "Z"], nargs="+", default=["Z"]) + parser.add_argument("--theta", type=float, nargs="+", default=[0.01, 0.05, 0.1]) + parser.add_argument("--shots", type=int, default=50000) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--no-statevec", action="store_true") + args = parser.parse_args() + + for dist in args.distance: + for basis in args.basis: + rds = args.rounds if args.rounds is not None else dist + run( + distance=dist, + rounds=rds, + basis=basis, + theta_values=args.theta, + shots=args.shots, + seed=args.seed, + run_statevec=not args.no_statevec, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/surface/eeg_vs_statevec.py b/examples/surface/eeg_vs_statevec.py new file mode 100644 index 000000000..82fd38915 --- /dev/null +++ b/examples/surface/eeg_vs_statevec.py @@ -0,0 +1,219 @@ +r"""Compare EEG analytical DEM vs StateVec empirical detection rates. + +Three approaches compared: + 1. Forward EEG (perturbative Taylor/SinSquared formula) — fast, approximate + 2. Backward Heisenberg (exact coherent + stochastic) — slower build, exact + 3. StateVec simulation (ground truth) — slow, limited by qubit count + +Both EEG approaches produce Stim-format DEM strings that can be sampled +at ~15M shots/sec via DemSampler.from_dem_string(). + +Example: + uv run python examples/surface/eeg_vs_statevec.py + uv run python examples/surface/eeg_vs_statevec.py --distance 3 --basis X --shots 20000 + uv run python examples/surface/eeg_vs_statevec.py --distance 2 --basis Z --shots 50000 + uv run python examples/surface/eeg_vs_statevec.py --distance 5 --basis Z --dem-sample +""" + +from __future__ import annotations + +import argparse +import json +import math +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python" / "quantum-pecos" / "src")) + + +def run_comparison( + *, + distance: int, + rounds: int, + basis: str, + theta_values: list[float], + shots: int, + seed: int, + dem_sample: bool, +): + from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch + from pecos_rslib_exp import ( + coherent_dem_exact, + depolarizing, + exact_detection_rates, + perturbative_dem, + perturbative_dem_events, + sim_neo, + statevec, + ) + + patch = SurfacePatch.create(distance=distance) + b = LogicalCircuitBuilder() + b.add_patch(patch, "Q0") + b.add_memory("Q0", rounds=rounds, basis=basis) + tc = b.to_tick_circuit() + + det_json = json.loads(tc.get_meta("detectors")) + num_meas = int(tc.get_meta("num_measurements")) + num_dets = len(det_json) + + print(f"\n{'='*70}") + print(f"d={distance} {basis}-basis memory, {rounds} rounds, {num_dets} detectors, {num_meas} measurements") + print(f"{'='*70}") + + for theta in theta_values: + print(f"\ntheta = {theta:.4f}") + + # --- Forward EEG DEM (Taylor) --- + t0 = time.perf_counter() + eeg_events = perturbative_dem_events(tc, idle_rz=theta, h_formula="taylor") + eeg_time = time.perf_counter() - t0 + + eeg_taylor = [0.0] * num_dets + for prob, det_ids, _obs_ids in eeg_events: + for d in det_ids: + if d < num_dets: + eeg_taylor[d] += prob + + # --- Heisenberg backward propagation --- + t0 = time.perf_counter() + heis_results = exact_detection_rates(tc, idle_rz=theta) + heis_time = time.perf_counter() - t0 + heis = [0.0] * num_dets + for det_id, prob in heis_results: + if det_id < num_dets: + heis[det_id] = prob + + # --- DEM sampling path (two-stage: build DEM once, sample fast) --- + if dem_sample: + from pecos_rslib.qec import DemSampler + + # Forward EEG DEM → sampler + t0 = time.perf_counter() + dem_taylor_str = perturbative_dem(tc, idle_rz=theta) + sampler_taylor = DemSampler.from_dem_string(dem_taylor_str) + batch_taylor = sampler_taylor.generate_samples(num_shots=shots, seed=seed) + taylor_sample_time = time.perf_counter() - t0 + + # Heisenberg DEM → sampler + t0 = time.perf_counter() + dem_heis_str = coherent_dem_exact(tc, idle_rz=theta) + sampler_heis = DemSampler.from_dem_string(dem_heis_str) + batch_heis = sampler_heis.generate_samples(num_shots=shots, seed=seed) + heis_sample_time = time.perf_counter() - t0 + + # Compute per-detector rates from DEM samples + taylor_dem_rates = [0.0] * num_dets + heis_dem_rates = [0.0] * num_dets + for i in range(shots): + syn_t = batch_taylor.get_syndrome(i) + syn_h = batch_heis.get_syndrome(i) + for d in range(min(num_dets, len(syn_t))): + if syn_t[d]: + taylor_dem_rates[d] += 1.0 / shots + if syn_h[d]: + heis_dem_rates[d] += 1.0 / shots + else: + taylor_dem_rates = None + heis_dem_rates = None + taylor_sample_time = 0 + heis_sample_time = 0 + + # --- StateVec simulation (ground truth) --- + t0 = time.perf_counter() + noise = depolarizing().idle_rz(theta) + results = sim_neo(tc).quantum(statevec()).noise(noise).shots(shots).seed(seed).run() + sv_time = time.perf_counter() - t0 + + sv_det_count = [0] * num_dets + for r in results: + meas = list(r) + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + sv_det_count[i] += 1 + + sv_det_rate = [c / shots for c in sv_det_count] + + # --- Compare --- + print(f" EEG: {eeg_time*1000:.1f}ms, Heisenberg: {heis_time*1000:.1f}ms") + print(f" StateVec: {sv_time:.1f}s ({shots} shots)") + if dem_sample: + print(f" DEM sample: Taylor {taylor_sample_time:.2f}s, Heisenberg {heis_sample_time:.2f}s ({shots} shots)") + + if dem_sample: + print( + f" {'Det':>4} {'Taylor':>10} {'Heisen':>10} {'T(DEM)':>10} " + f"{'H(DEM)':>10} {'StateVec':>10} {'SV_se':>8} {'T/SV':>7} {'H/SV':>7}", + ) + else: + print(f" {'Det':>4} {'Taylor':>10} {'Heisen':>10} {'StateVec':>10} {'SV_se':>8} {'T/SV':>7} {'H/SV':>7}") + + max_rel_taylor = 0.0 + max_rel_heis = 0.0 + for d in range(num_dets): + tp = eeg_taylor[d] + hp = heis[d] + sv_r = sv_det_rate[d] + sv_se = math.sqrt(sv_r * (1 - sv_r) / shots) if shots > 0 else 0 + + if sv_r > 0.0001: + t_ratio = tp / sv_r + h_ratio = hp / sv_r + max_rel_taylor = max(max_rel_taylor, abs(tp - sv_r) / sv_r) + max_rel_heis = max(max_rel_heis, abs(hp - sv_r) / sv_r) + else: + t_ratio = float("nan") + h_ratio = float("nan") + + if dem_sample: + td = taylor_dem_rates[d] if taylor_dem_rates else 0 + hd = heis_dem_rates[d] if heis_dem_rates else 0 + print( + f" D{d:>3} {tp:>10.6f} {hp:>10.6f} {td:>10.6f} {hd:>10.6f} " + f"{sv_r:>10.6f} {sv_se:>8.6f} {t_ratio:>7.3f} {h_ratio:>7.3f}", + ) + else: + print( + f" D{d:>3} {tp:>10.6f} {hp:>10.6f} {sv_r:>10.6f} " + f"{sv_se:>8.6f} {t_ratio:>7.3f} {h_ratio:>7.3f}", + ) + + print(f" Max rel err: Taylor={max_rel_taylor*100:.1f}%, Heisenberg={max_rel_heis*100:.1f}%") + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--distance", "-d", type=int, nargs="+", default=[2, 3]) + parser.add_argument("--rounds", type=int, default=None) + parser.add_argument("--basis", choices=["X", "Z"], nargs="+", default=["X", "Z"]) + parser.add_argument("--theta", type=float, nargs="+", default=[0.01, 0.03, 0.05, 0.1]) + parser.add_argument("--shots", type=int, default=20000) + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--dem-sample", action="store_true", help="Also sample from both DEMs and compare rates") + args = parser.parse_args() + + for dist in args.distance: + for basis in args.basis: + rds = args.rounds if args.rounds is not None else dist + run_comparison( + distance=dist, + rounds=rds, + basis=basis, + theta_values=args.theta, + shots=args.shots, + seed=args.seed, + dem_sample=args.dem_sample, + ) + + +if __name__ == "__main__": + main() diff --git a/examples/surface/generate_data.py b/examples/surface/generate_data.py new file mode 100644 index 000000000..1eb99df7b --- /dev/null +++ b/examples/surface/generate_data.py @@ -0,0 +1,346 @@ +r"""Generate decoder performance data for surface code memory experiments. + +Samples detection events once per (distance, error_rate, rounds) point, +then decodes with each requested decoder. Writes a JSON shard that can +be fed to ``analyze_data.py`` and ``build_report.py``. + +Example: + uv run python examples/surface/generate_data.py \\ + --distances 3 5 --error-rates 0.004 0.008 \\ + --decoders pymatching mwpf tesseract bp_osd \\ + --shots 5000 + + uv run python examples/surface/generate_data.py \\ + --distances 3 5 7 \\ + --error-rates 0.002 0.004 0.006 0.008 \\ + --decoders pymatching mwpf tesseract \\ + --duration-multipliers 2 2.5 3 \\ + --shots 2000 --output-dir results/ +""" + +from __future__ import annotations + +import argparse +import json +import time +from dataclasses import asdict, dataclass, field +from pathlib import Path +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pecos.qec.surface import NoiseModel + +# -- Data model --------------------------------------------------------------- + + +@dataclass +class DecoderStats: + """Decode statistics for one decoder on one set of samples.""" + + decoder: str + num_errors: int + logical_error_rate: float + total_decode_seconds: float + per_shot_mean: float + per_shot_median: float + per_shot_p99: float + per_shot_max: float + # 21 quantiles at [0%, 5%, 10%, ..., 95%, 100%] for violin plots + quantiles: list[float] = field(default_factory=list) + + +@dataclass +class DataPoint: + """Raw data for one (distance, basis, p, rounds, decoder-set) cell.""" + + distance: int + basis: str + physical_error_rate: float + num_rounds: int + num_shots: int + sample_seconds: float + decoder_stats: list[DecoderStats] = field(default_factory=list) + + +@dataclass +class DataShard: + """One complete data-generation run. Serialised to JSON.""" + + config: dict + points: list[DataPoint] = field(default_factory=list) + total_seconds: float = 0.0 + + +# -- DEM sets for different decoder families ---------------------------------- + +# MWPM decoders need decomposed (graphlike) DEMs. +_MWPM_DECODERS = { + "pymatching", + "pymatching_uncorrelated", + "fusion_blossom", + "fusion_blossom_serial", + "fusion_blossom_parallel", +} + +# Slow decoders benefit from parallel decode. +_SLOW_DECODERS = {"tesseract", "mwpf", "bp_osd", "relay_bp"} + + +def _decoder_base_name(name: str) -> str: + """Strip config suffix: 'mwpf:c=30' -> 'mwpf'.""" + return name.split(":", maxsplit=1)[0] + + +# -- Sampler + DEM construction ----------------------------------------------- + + +def _build_sampler( + distance: int, + num_rounds: int, + noise: NoiseModel, + basis: str, + circuit_source: str, +) -> tuple: + """Build native sampler and return (sampler, dem_decomposed, dem_full).""" + from pecos.qec.surface import SurfacePatch, build_native_sampler + from pecos.qec.surface.decode import SurfaceDecoder, generate_circuit_level_dem_from_builder + + patch = SurfacePatch.create(distance=distance) + sampler = build_native_sampler( + patch, + num_rounds, + noise, + basis=basis, + circuit_source=circuit_source, + ) + + # Decomposed DEM for MWPM decoders + dec = SurfaceDecoder( + patch, + num_rounds=num_rounds, + noise=noise, + decoder_type="pymatching", + use_circuit_level_dem=True, + circuit_level_dem_mode="native_decomposed", + circuit_level_dem_source=circuit_source, + ) + dem_decomp = dec.get_dem(basis.upper(), circuit_level=True) + dem_decomp = "\n".join(line for line in dem_decomp.split("\n") if not line.startswith("logical_observable")) + + # Full DEM for non-MWPM decoders + dem_full = generate_circuit_level_dem_from_builder( + patch, + num_rounds, + noise, + basis=basis, + decompose_errors=False, + circuit_source=circuit_source, + ) + dem_full = "\n".join(line for line in dem_full.split("\n") if not line.startswith("logical_observable")) + + return sampler, dem_decomp, dem_full + + +# -- Main generation loop ----------------------------------------------------- + + +def generate( + *, + distances: list[int], + error_rates: list[float], + decoders: list[str], + basis: str, + shots: int, + seed: int, + circuit_source: str, + p1_scale: float, + p_meas_scale: float, + p_prep_scale: float, + duration_multipliers: list[float], +) -> DataShard: + """Run the full data generation and return a shard.""" + from pecos.qec.surface import NoiseModel + + config = { + "distances": distances, + "error_rates": error_rates, + "decoders": decoders, + "basis": basis.upper(), + "shots": shots, + "seed": seed, + "circuit_source": circuit_source, + "p1_scale": p1_scale, + "p_meas_scale": p_meas_scale, + "p_prep_scale": p_prep_scale, + "duration_multipliers": duration_multipliers, + } + + shard = DataShard(config=config) + t_start = time.perf_counter() + + # Compute distinct round counts per distance. + # For small d, multipliers may collide after truncation. + # Ensure at least len(duration_multipliers) distinct round counts + # by extending the range upward if needed. + rounds_per_d: dict[int, list[int]] = {} + for d in distances: + seen = set() + for mult in duration_multipliers: + seen.add(max(2, int(d * mult))) + # If we got fewer than requested, fill in consecutive integers from 2*d + target = len(duration_multipliers) + r_start = 2 * d + while len(seen) < target: + seen.add(r_start) + r_start += 1 + rounds_per_d[d] = sorted(seen) + + total_cells = sum(len(rounds_per_d[d]) for d in distances) * len(error_rates) + cell_idx = 0 + + for d in distances: + for p in error_rates: + noise = NoiseModel( + p1=p * p1_scale, + p2=p, + p_meas=p * p_meas_scale, + p_prep=p * p_prep_scale, + ) + + for num_rounds in rounds_per_d[d]: + cell_idx += 1 + print(f"[{cell_idx}/{total_cells}] d={d} p={p:.4g} r={num_rounds} ...") + + sampler, dem_decomp, dem_full = _build_sampler( + d, + num_rounds, + noise, + basis, + circuit_source, + ) + + # Sample once + t0 = time.perf_counter() + batch = sampler.sampler.generate_samples(shots, seed=seed + cell_idx) + sample_seconds = time.perf_counter() - t0 + + point = DataPoint( + distance=d, + basis=basis.upper(), + physical_error_rate=p, + num_rounds=num_rounds, + num_shots=shots, + sample_seconds=sample_seconds, + ) + + # Decode with each decoder + for decoder_name in decoders: + base = _decoder_base_name(decoder_name) + dem = dem_decomp if base in _MWPM_DECODERS else dem_full + + if base in _SLOW_DECODERS: + stats = batch.decode_stats_parallel(dem, decoder_name) + else: + stats = batch.decode_stats(dem, decoder_name) + + point.decoder_stats.append( + DecoderStats( + decoder=decoder_name, + num_errors=stats.num_errors, + logical_error_rate=stats.logical_error_rate, + total_decode_seconds=stats.total_seconds, + per_shot_mean=stats.per_shot_mean, + per_shot_median=stats.per_shot_median, + per_shot_p99=stats.per_shot_p99, + per_shot_max=stats.per_shot_max, + quantiles=list(stats.quantiles), + ), + ) + + print( + f" {decoder_name:20s}: {stats.num_errors:>4d}/{shots} " + f"LER={stats.logical_error_rate:.4f} " + f"median={stats.per_shot_median:.1e}s " + f"p99={stats.per_shot_p99:.1e}s", + ) + + shard.points.append(point) + + shard.total_seconds = time.perf_counter() - t_start + return shard + + +# -- CLI ---------------------------------------------------------------------- + + +def main() -> int: + """CLI entry point for data generation.""" + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument("--distances", nargs="+", type=int, default=[3, 5]) + parser.add_argument("--error-rates", nargs="+", type=float, default=[0.004, 0.006, 0.008]) + parser.add_argument( + "--decoders", + nargs="+", + default=["pymatching", "mwpf", "tesseract", "bp_osd"], + help="Decoders to run. Use 'mwpf:c=30,t=0.5' for config overrides.", + ) + parser.add_argument("--shots", type=int, default=1000) + parser.add_argument("--basis", default="Z") + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--circuit-source", default="traced_qis", choices=["traced_qis", "abstract"]) + parser.add_argument("--p1-scale", type=float, default=1.0 / 30.0) + parser.add_argument("--p-meas-scale", type=float, default=1.0 / 3.0) + parser.add_argument("--p-prep-scale", type=float, default=1.0 / 3.0) + parser.add_argument( + "--duration-multipliers", + nargs="+", + type=float, + default=[2.0], + help="Round count = distance * multiplier. Use multiple for threshold fitting.", + ) + parser.add_argument("--output-dir", type=str, default=None) + args = parser.parse_args() + + print("PECOS Data Generation") + print("=" * 40) + for k, v in vars(args).items(): + if k != "output_dir": + print(f" {k}: {v}") + print() + + shard = generate( + distances=sorted(args.distances), + error_rates=sorted(args.error_rates), + decoders=args.decoders, + basis=args.basis, + shots=args.shots, + seed=args.seed, + circuit_source=args.circuit_source, + p1_scale=args.p1_scale, + p_meas_scale=args.p_meas_scale, + p_prep_scale=args.p_prep_scale, + duration_multipliers=sorted(args.duration_multipliers), + ) + + print(f"\nTotal time: {shard.total_seconds:.1f}s") + + if args.output_dir: + out = Path(args.output_dir) + else: + import tempfile + + out = Path(tempfile.mkdtemp(prefix="pecos_data_")) + + out.mkdir(parents=True, exist_ok=True) + json_path = out / "data.json" + json_path.write_text(json.dumps(asdict(shard), indent=2)) + print(f"Wrote {json_path}") + + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/examples/surface/ml_lookup_decoder.py b/examples/surface/ml_lookup_decoder.py new file mode 100644 index 000000000..6fefa9ca2 --- /dev/null +++ b/examples/surface/ml_lookup_decoder.py @@ -0,0 +1,254 @@ +"""Maximum likelihood lookup decoder from simulation samples. + +For small codes (d=3: 256 syndromes), precomputes the optimal correction +for every possible syndrome by counting simulation outcomes. This is the +provably optimal decoder — useful as a gold standard for validation. + +Example: + uv run python examples/surface/ml_lookup_decoder.py +""" + +from __future__ import annotations + +import argparse +import sys +import time +from collections import defaultdict +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python" / "quantum-pecos" / "src")) + + +def build_lookup_table(batch, num_detectors: int) -> dict[tuple[int, ...], int]: + """Build a syndrome -> most_likely_observable_mask lookup table. + + For each unique syndrome observed in the batch, count how often each + observable mask occurs. The most common mask is the ML prediction. + """ + # syndrome (as tuple of fired detector indices) -> {obs_mask: count} + syndrome_counts: dict[tuple[int, ...], dict[int, int]] = defaultdict(lambda: defaultdict(int)) + + for i in range(batch.num_shots): + syn = batch.get_syndrome(i) + obs = batch.get_observable_mask(i) + + # Convert syndrome to tuple of fired detector indices + fired = tuple(d for d in range(min(num_detectors, len(syn))) if syn[d]) + syndrome_counts[fired][obs] += 1 + + # For each syndrome, pick the most likely observable mask + table: dict[tuple[int, ...], int] = {} + for syndrome, counts in syndrome_counts.items(): + best_mask = max(counts, key=counts.get) + table[syndrome] = best_mask + + return table + + +def decode_with_lookup(batch, table: dict, num_detectors: int) -> tuple[int, int]: + """Decode a batch using the lookup table. Returns (num_errors, num_shots).""" + errors = 0 + for i in range(batch.num_shots): + syn = batch.get_syndrome(i) + obs_true = batch.get_observable_mask(i) + + fired = tuple(d for d in range(min(num_detectors, len(syn))) if syn[d]) + predicted = table.get(fired, 0) # default: no correction + + if predicted != obs_true: + errors += 1 + + return errors, batch.num_shots + + +def main(): + parser = argparse.ArgumentParser(description="ML lookup decoder from simulation") + parser.add_argument("--distance", type=int, default=3) + parser.add_argument("--shots", type=int, default=50_000) + parser.add_argument("--basis", default="Z") + parser.add_argument("--seed", type=int, default=42) + parser.add_argument("--p2", type=float, default=0.005) + parser.add_argument("--idle-rz", type=float, default=0.0) + parser.add_argument( + "--sample-backend", + default="stabilizer", + choices=["stabilizer", "statevec", "native"], + ) + args = parser.parse_args() + + import json + + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=args.distance) + num_rounds = 2 * args.distance + tc = _build_surface_tick_circuit_for_native_model( + patch, + num_rounds, + args.basis, + circuit_source="abstract", + ) + + num_dets = len(json.loads(tc.get_meta("detectors"))) + print(f"d={args.distance}, {num_rounds} rounds, {num_dets} detectors, {2**num_dets} possible syndromes") + + noise_params = { + "p1": args.p2 / 10, + "p2": args.p2, + "p_meas": args.p2, + "p_prep": args.p2, + "idle_rz": args.idle_rz, + } + + # Generate training samples + print(f"Generating {args.shots} training samples ({args.sample_backend})...") + t0 = time.perf_counter() + + if args.sample_backend in ("stabilizer", "statevec"): + from pecos_rslib.qec import SampleBatch + from pecos_rslib_exp import depolarizing, sim_neo, stabilizer, statevec + + noise = ( + depolarizing() + .p1(noise_params["p1"]) + .p2(noise_params["p2"]) + .p_meas(noise_params["p_meas"]) + .p_prep(noise_params["p_prep"]) + ) + if args.idle_rz > 0: + noise = noise.idle_rz(args.idle_rz) + + backend = stabilizer() if args.sample_backend == "stabilizer" else statevec() + results = sim_neo(tc).quantum(backend).noise(noise).shots(args.shots).seed(args.seed).run() + + det_json = json.loads(tc.get_meta("detectors")) + obs_json = json.loads(tc.get_meta("observables")) + num_meas = int(tc.get_meta("num_measurements")) + + # Convert to SampleBatch + detection_events = [] + observable_masks = [] + for r in results: + meas = list(r) + syn = [0] * num_dets + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + syn[i] = val + detection_events.append(syn) + obs_mask = 0 + for j, ob in enumerate(obs_json): + val = 0 + for rec in ob["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + obs_mask |= 1 << j + observable_masks.append(obs_mask) + train_batch = SampleBatch(detection_events, observable_masks) + else: + from pecos_rslib.qec import DemSampler + + sampler_params = {k: v for k, v in noise_params.items() if k in ("p1", "p2", "p_meas", "p_prep")} + sampler = DemSampler.from_circuit(tc, **sampler_params) + train_batch = sampler.generate_samples(args.shots, seed=args.seed) + + t_sample = time.perf_counter() - t0 + print(f" Sampled in {t_sample:.2f}s") + + # Build lookup table + t0 = time.perf_counter() + table = build_lookup_table(train_batch, num_dets) + t_build = time.perf_counter() - t0 + print(f" Lookup table: {len(table)} unique syndromes seen (of {2**num_dets} possible)") + print(f" Built in {t_build:.4f}s") + + # Test on separate samples + test_shots = args.shots + print(f"\nGenerating {test_shots} test samples...") + if args.sample_backend in ("stabilizer", "statevec"): + results2 = sim_neo(tc).quantum(backend).noise(noise).shots(test_shots).seed(args.seed + 1000).run() + detection_events2 = [] + observable_masks2 = [] + for r in results2: + meas = list(r) + syn = [0] * num_dets + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + syn[i] = val + detection_events2.append(syn) + obs_mask = 0 + for j, ob in enumerate(obs_json): + val = 0 + for rec in ob["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + obs_mask |= 1 << j + observable_masks2.append(obs_mask) + test_batch = SampleBatch(detection_events2, observable_masks2) + else: + test_batch = sampler.generate_samples(test_shots, seed=args.seed + 1000) + + # Decode with lookup + errors_lookup, n = decode_with_lookup(test_batch, table, num_dets) + ler_lookup = errors_lookup / n + + # Compare with pymatching + from pecos.qec.surface import NoiseModel + from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder + + noise_obj = NoiseModel( + p1=noise_params["p1"], + p2=noise_params["p2"], + p_meas=noise_params["p_meas"], + p_prep=noise_params["p_prep"], + ) + dem_decomp = generate_circuit_level_dem_from_builder( + patch, + num_rounds, + noise_obj, + basis=args.basis, + decompose_errors=True, + circuit_source="abstract", + ) + dem_clean = "\n".join(line for line in dem_decomp.split("\n") if not line.startswith("logical_observable")) + stats_pm = test_batch.decode_stats(dem_clean, "pymatching") + + # Compare with coherent_dem_decomposed if available + try: + from pecos_rslib_exp import coherent_dem_decomposed + + _, coherent_decomp = coherent_dem_decomposed(tc, **noise_params) + coherent_clean = "\n".join( + line for line in coherent_decomp.split("\n") if not line.startswith("logical_observable") + ) + stats_coherent = test_batch.decode_stats(coherent_clean, "pymatching") + ler_coherent = stats_coherent.logical_error_rate + except Exception: + ler_coherent = None + + print(f"\n{'='*60}") + print(f"Results (d={args.distance}, p2={args.p2}, irz={args.idle_rz}):") + print(f"{'='*60}") + print(f" ML Lookup: LER = {ler_lookup:.6f} ({errors_lookup}/{n})") + print(f" PyMatching (from_circuit): LER = {stats_pm.logical_error_rate:.6f} ({stats_pm.num_errors}/{n})") + if ler_coherent is not None: + print(f" PyMatching (coherent): LER = {ler_coherent:.6f}") + if stats_pm.logical_error_rate > 0: + improvement = (stats_pm.logical_error_rate - ler_lookup) / stats_pm.logical_error_rate * 100 + print(f"\n ML Lookup vs PyMatching: {improvement:+.1f}%") + + +if __name__ == "__main__": + main() diff --git a/examples/surface/native_dem_threshold_sweep.py b/examples/surface/native_dem_threshold_sweep.py index 4afb371c8..4839a9ad3 100755 --- a/examples/surface/native_dem_threshold_sweep.py +++ b/examples/surface/native_dem_threshold_sweep.py @@ -8,8 +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 uniform depolarizing noise model with ``p1 = p2 = p_meas = p_init = p`` -- ``SurfaceDecoder(..., decoder_type="pymatching")`` with PECOS-native DEMs +- a depolarizing noise model with ``p2 = p``, ``p1 = p/30``, ``p_meas = p_prep = p/3`` +- ``SurfaceDecoder(...)`` with PECOS-native DEMs (PyMatching or Tesseract) For the ``sim`` backend, decoding is performed relative to a cached noiseless reference trajectory from the same Guppy/QIS circuit. This makes the gate-level @@ -68,6 +68,7 @@ if TYPE_CHECKING: from types import ModuleType + import numpy as np from matplotlib.axes import Axes from matplotlib.figure import Figure from matplotlib.patches import Rectangle @@ -231,6 +232,7 @@ class _NativeSamplerRuntime: decoder_runtime: _DecoderRuntime sampler: Any dem_decoder: Any + dem_str: str | None = None _CACHED_SELENE_INSTANCES: list[Any] = [] @@ -251,6 +253,11 @@ def _cleanup_cached_selene_instances() -> None: def _backend_runtime_label(sample_backend: str, native_circuit_source: str = "abstract") -> str: """Describe one sampling backend in human-readable terms.""" + # Handle "backend:decoder" labels from multi-decoder comparison + if ":" in sample_backend: + base, decoder = sample_backend.split(":", 1) + base_label = _backend_runtime_label(base, native_circuit_source) + return f"{base_label} [decoder={decoder}]" if sample_backend == "sim": return ( "sim(Guppy(...)).classical(selene_engine()).quantum(pecos.stabilizer()) " @@ -270,7 +277,7 @@ def _backend_runtime_label(sample_backend: str, native_circuit_source: str = "ab f"{native_circuit_source} + noiseless reference-trajectory calibration" ) if sample_backend == "native_sampler": - return f"build_native_sampler(..., circuit_source={native_circuit_source!r}) + PyMatching on the native DEM" + return f"build_native_sampler(..., circuit_source={native_circuit_source!r}) + DEM decoder on the native DEM" msg = f"Unknown sample backend: {sample_backend}" raise ValueError(msg) @@ -537,6 +544,106 @@ def _surface_patch(distance: int) -> object: return SurfacePatch.create(distance=distance) +_CHECK_MATRIX_DECODERS = {"bp_osd", "bp_lsd", "union_find", "relay_bp", "min_sum_bp"} + + +def _noise_model_description(args: argparse.Namespace) -> str: + """Human-readable noise model string for reports.""" + 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" + + +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. + """ + if decoder_type == "tesseract": + from pecos_rslib.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 + + 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 + + return PyMatchingDecoder.from_dem(dem_str) + + +def _decode_one_shot(dem_decoder: object, events_flat: list[int]) -> object: + """Decode one shot using whichever DEM decoder was created. + + Tesseract.decode() wants sparse indices; decode_syndrome() accepts dense vectors. + PyMatching.decode() accepts dense vectors directly. + """ + if hasattr(dem_decoder, "decode_syndrome"): + return dem_decoder.decode_syndrome(events_flat) + return dem_decoder.decode(events_flat) + + +def _decode_all_shots( + dem_decoder: object, + detection_events: np.ndarray, + observable_flips: np.ndarray, + num_shots: int, +) -> int: + """Decode all shots using the fastest available path. + + For PyMatching: uses decode_batch with flattened numpy array (no Python loop). + For Tesseract: uses decode_batch with parallel rayon workers. + For others: falls back to per-shot Python loop. + + Returns the number of logical errors. + """ + import numpy as np + + true_flips = ( + observable_flips[:, 0].astype(np.uint8) + if observable_flips.shape[1] > 0 + else np.zeros(num_shots, dtype=np.uint8) + ) + + # PyMatching batch: takes flattened (num_shots * num_detectors) u8 array + from pecos_rslib.decoders import PyMatchingDecoder + + if isinstance(dem_decoder, PyMatchingDecoder): + flat = detection_events.astype(np.uint8).flatten().tolist() + predictions = dem_decoder.decode_batch(flat, num_shots) + # Each prediction is a list of observables; check index 0 + predicted = np.array([p[0] if p else 0 for p in predictions], dtype=np.uint8) + return int(np.sum(predicted != true_flips)) + + # Tesseract batch: takes list of syndromes, parallel rayon + from pecos_rslib.decoders import TesseractDecoder + + if isinstance(dem_decoder, TesseractDecoder): + syndromes = [detection_events[i].astype(np.uint8).tolist() for i in range(num_shots)] + batch_results = dem_decoder.decode_batch(syndromes) + num_errors = 0 + for shot_idx, result in enumerate(batch_results): + predicted_flip = int(result.observables_mask & 1) + num_errors += int(predicted_flip != true_flips[shot_idx]) + return num_errors + + # Fallback: per-shot loop (DemAwareDecoder, etc.) + num_errors = 0 + for shot_idx in range(num_shots): + events_flat = detection_events[shot_idx].astype(np.uint8).tolist() + decode_result = _decode_one_shot(dem_decoder, events_flat) + predicted_flip = _predicted_observable_flip(decode_result) + num_errors += int(predicted_flip != true_flips[shot_idx]) + return num_errors + + @cache def _decoder_runtime( distance: int, @@ -545,6 +652,11 @@ def _decoder_runtime( physical_error_rate: float, dem_mode: str, native_circuit_source: str, + decoder_type: str = "pymatching", + ancilla_budget: int | None = None, + p1_scale: float = 0.1, + p_meas_scale: float = 0.5, + p_prep_scale: float = 0.5, ) -> _DecoderRuntime: """Build and cache the expensive native decoder-side objects once.""" from pecos.qec.surface import NoiseModel, SurfaceDecoder @@ -552,19 +664,20 @@ def _decoder_runtime( basis = basis.upper() patch = _surface_patch(distance) noise = NoiseModel( - p1=physical_error_rate, + p1=physical_error_rate * p1_scale, p2=physical_error_rate, - p_meas=physical_error_rate, - p_init=physical_error_rate, + p_meas=physical_error_rate * p_meas_scale, + p_prep=physical_error_rate * p_prep_scale, ) decoder = SurfaceDecoder( patch, num_rounds=total_rounds, noise=noise, - decoder_type="pymatching", + decoder_type=decoder_type, use_circuit_level_dem=True, circuit_level_dem_mode=dem_mode, circuit_level_dem_source=native_circuit_source, + ancilla_budget=ancilla_budget, ) return _DecoderRuntime( patch=patch, @@ -584,10 +697,15 @@ def _native_sampler_runtime( physical_error_rate: float, dem_mode: str, native_circuit_source: str, + decoder_type: str = "pymatching", + ancilla_budget: int | None = None, + p1_scale: float = 0.1, + p_meas_scale: float = 0.5, + p_prep_scale: float = 0.5, ) -> _NativeSamplerRuntime: - """Build and cache the native sampler + PyMatching decoder bundle once.""" + """Build and cache the native sampler + decoder bundle once.""" from pecos.qec.surface import build_native_sampler - from pecos_rslib.decoders import PyMatchingDecoder + from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder runtime = _decoder_runtime( distance, @@ -596,6 +714,11 @@ def _native_sampler_runtime( physical_error_rate, dem_mode, native_circuit_source, + decoder_type=decoder_type, + ancilla_budget=ancilla_budget, + p1_scale=p1_scale, + p_meas_scale=p_meas_scale, + p_prep_scale=p_prep_scale, ) sampler = build_native_sampler( runtime.patch, @@ -603,18 +726,35 @@ def _native_sampler_runtime( runtime.noise, basis=basis, circuit_source=native_circuit_source, + ancilla_budget=ancilla_budget, ) - dem_str = runtime.decoder.get_dem(basis.upper(), circuit_level=True) - dem_decoder = PyMatchingDecoder.from_dem(dem_str) + # PyMatching needs decomposed (graph-like) DEMs; Tesseract and check-matrix + # decoders handle hyperedges natively and should get the full DEM. + if decoder_type == "pymatching": + dem_str = runtime.decoder.get_dem(basis.upper(), circuit_level=True) + else: + dem_str = generate_circuit_level_dem_from_builder( + runtime.patch, + total_rounds, + runtime.noise, + basis=basis, + decompose_errors=False, + circuit_source=native_circuit_source, + ancilla_budget=ancilla_budget, + ) + dem_decoder = _create_dem_decoder(decoder_type, dem_str) # The traced-QIS sampler stack has a noticeable one-time initialization cost # on its first sample. Pay that once when the cached runtime is created so # subsequent point evaluations stay on the true steady-state path. warm_det_events, _ = sampler.sample(num_shots=1, seed=0) - dem_decoder.decode(warm_det_events[0].astype(int).tolist()) + _decode_one_shot(dem_decoder, warm_det_events[0].astype(int).tolist()) + # Filter logical_observable lines for decoders that need it + dem_str_filtered = "\n".join(line for line in dem_str.split("\n") if not line.startswith("logical_observable")) return _NativeSamplerRuntime( decoder_runtime=runtime, sampler=sampler, dem_decoder=dem_decoder, + dem_str=dem_str_filtered, ) @@ -694,6 +834,9 @@ def _run_gate_backend_result_dict( num_shots: int, seed: int, timing_sink: dict[str, float] | None = None, + p1_scale: float = 0.1, + p_meas_scale: float = 0.5, + p_prep_scale: float = 0.5, ) -> dict[str, list[list[int]]]: """Run one gate-level backend and normalize results to a shot-map-like dict.""" import os @@ -731,10 +874,10 @@ def run_direct_selene_backend(*, simulator: object) -> dict[str, list[list[int]] error_model_start = time.perf_counter() error_model = DepolarizingErrorModel( - p_1q=physical_error_rate, + p_1q=physical_error_rate * p1_scale, p_2q=physical_error_rate, - p_meas=physical_error_rate, - p_init=physical_error_rate, + p_meas=physical_error_rate * p_meas_scale, + p_prep=physical_error_rate * p_prep_scale, ) error_model_seconds = time.perf_counter() - error_model_start @@ -771,7 +914,14 @@ 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().with_uniform_probability(physical_error_rate) + 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) + ) noise_seconds = time.perf_counter() - noise_start program_start = time.perf_counter() program = make_surface_code(distance=distance, num_rounds=total_rounds, basis=basis) @@ -946,6 +1096,12 @@ def _run_memory_point( dem_mode: str, native_circuit_source: str, seed: int, + decoder_type: str = "pymatching", + backend_label: str | None = None, + ancilla_budget: int | None = None, + p1_scale: float = 0.1, + p_meas_scale: float = 0.5, + p_prep_scale: float = 0.5, ) -> SweepPoint: """Run one surface-memory point and decode it with native PECOS DEMs.""" import numpy as np @@ -958,6 +1114,11 @@ def _run_memory_point( physical_error_rate, dem_mode, native_circuit_source, + decoder_type=decoder_type, + ancilla_budget=ancilla_budget, + p1_scale=p1_scale, + p_meas_scale=p_meas_scale, + p_prep_scale=p_prep_scale, ) patch = decoder_runtime.patch num_x_stab = decoder_runtime.num_x_stab @@ -986,6 +1147,9 @@ def _run_memory_point( total_rounds=total_rounds, num_shots=num_shots, seed=seed, + p1_scale=p1_scale, + p_meas_scale=p_meas_scale, + p_prep_scale=p_prep_scale, ) synx_rows = _result_rows_for_key(result_dict, "synx") @@ -1043,18 +1207,40 @@ def _run_memory_point( physical_error_rate, dem_mode, native_circuit_source, + decoder_type=decoder_type, + ancilla_budget=ancilla_budget, + p1_scale=p1_scale, + p_meas_scale=p_meas_scale, + p_prep_scale=p_prep_scale, ) 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 - for shot_idx in range(num_shots): - events_flat = detection_events[shot_idx].astype(np.uint8).tolist() - decode_result = dem_decoder.decode(events_flat) - predicted_flip = _predicted_observable_flip(decode_result) - true_flip = int(observable_flips[shot_idx, 0]) if observable_flips.shape[1] > 0 else 0 - num_logical_errors += int(predicted_flip != true_flip) + # 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) + if dem_str_for_rust and rust_sampler and hasattr(rust_sampler, "sample_decode_count"): + # 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( + dem_str_for_rust, + num_shots, + decoder_type, + seed, + ) + else: + num_logical_errors = rust_sampler.sample_decode_count( + dem_str_for_rust, + num_shots, + decoder_type, + seed, + ) + else: + detection_events, observable_flips = sampler.sample(num_shots=num_shots, seed=seed) + num_logical_errors = _decode_all_shots(dem_decoder, detection_events, observable_flips, num_shots) else: msg = f"Unknown sample backend: {sample_backend}" raise ValueError(msg) @@ -1063,7 +1249,7 @@ def _run_memory_point( raw_error_rate = None if num_raw_errors is None else (num_raw_errors / num_shots if num_shots else 0.0) return SweepPoint( - backend=sample_backend, + backend=backend_label or sample_backend, distance=distance, basis=basis.upper(), physical_error_rate=physical_error_rate, @@ -1569,6 +1755,14 @@ def _basis_summary(summaries: list[FitSummary]) -> dict[str, Any]: for p, is_suppressed in _suppression_summary(summaries) ], "background_threshold_crossing": _estimate_threshold(summaries), + "background_threshold_crossing_per_round": _estimate_threshold( + summaries, + metric="fitted_logical_error_rate_per_round", + ), + "background_threshold_crossing_d_rounds": _estimate_threshold( + summaries, + metric="fitted_projected_logical_error_rate_over_d_rounds", + ), "background_threshold_style_global_scaling_fit": None if global_scaling is None else asdict(global_scaling), } @@ -1685,7 +1879,7 @@ def _write_json_results( backend: _backend_runtime_label(backend, args.native_circuit_source) for backend in sorted({point.backend for point in points}) }, - "noise_model": "uniform depolarizing with p1 = p2 = p_meas = p_init = p", + "noise_model": _noise_model_description(args), "fit_model": "p_L(r) = 0.5 * (1 - (1 - 2 * epsilon) ** r)", "primary_power_law_model": "epsilon_d(p) ~= A_d * p ** c_d", "primary_lambda_model": "Lambda_{d/(d+2)}(p) = epsilon_d(p) / epsilon_{d+2}(p)", @@ -2212,18 +2406,45 @@ def plot_card(plot: DashboardPlot) -> list[str]: ] style = dedent( """ - :root { color-scheme: light; } + :root { + color-scheme: light dark; + --bg: #f8fafc; --fg: #0f172a; + --hero-bg: linear-gradient(135deg, #e0f2fe, #f8fafc 55%, #dcfce7); + --hero-border: #cbd5e1; + --card-bg: white; --card-border: #dbeafe; --card-shadow: rgba(15,23,42,0.05); + --meta-bg: rgba(255,255,255,0.82); + --muted: #475569; --link: #2563eb; --link-alt: #0369a1; + --header-border: #e2e8f0; + --img-bg: white; + } + [data-theme="dark"] { + --bg: #0f172a; --fg: #e2e8f0; + --hero-bg: linear-gradient(135deg, #1e293b, #0f172a 55%, #1a2e1a); + --hero-border: #334155; + --card-bg: #1e293b; --card-border: #334155; --card-shadow: rgba(0,0,0,0.3); + --meta-bg: rgba(30,41,59,0.82); + --muted: #94a3b8; --link: #60a5fa; --link-alt: #38bdf8; + --header-border: #334155; + --img-bg: #1e293b; + } body { margin: 0; font-family: ui-sans-serif, -apple-system, BlinkMacSystemFont, sans-serif; - background: #f8fafc; - color: #0f172a; + background: var(--bg); + color: var(--fg); } main { max-width: 1500px; margin: 0 auto; padding: 32px 24px 56px; } h1, h2, h3, p { margin-top: 0; } + .theme-toggle { + position: fixed; top: 16px; right: 16px; z-index: 100; + background: var(--card-bg); border: 1px solid var(--card-border); + border-radius: 8px; padding: 6px 12px; cursor: pointer; + color: var(--fg); font-size: 0.85rem; font-weight: 600; + } + .theme-toggle:hover { opacity: 0.8; } .hero { - background: linear-gradient(135deg, #e0f2fe, #f8fafc 55%, #dcfce7); - border: 1px solid #cbd5e1; + background: var(--hero-bg); + border: 1px solid var(--hero-border); border-radius: 20px; padding: 24px; margin-bottom: 24px; @@ -2235,8 +2456,8 @@ def plot_card(plot: DashboardPlot) -> list[str]: margin-top: 18px; } .meta-card { - background: rgba(255,255,255,0.82); - border: 1px solid #dbeafe; + background: var(--meta-bg); + border: 1px solid var(--card-border); border-radius: 14px; padding: 14px 16px; } @@ -2245,7 +2466,7 @@ def plot_card(plot: DashboardPlot) -> list[str]: font-size: 0.82rem; text-transform: uppercase; letter-spacing: 0.04em; - color: #475569; + color: var(--muted); margin-bottom: 6px; } .section { margin-top: 30px; } @@ -2255,44 +2476,67 @@ def plot_card(plot: DashboardPlot) -> list[str]: gap: 18px; } .plot-card { - background: white; - border: 1px solid #dbeafe; + background: var(--card-bg); + border: 1px solid var(--card-border); border-radius: 18px; overflow: hidden; - box-shadow: 0 10px 24px rgba(15, 23, 42, 0.05); + box-shadow: 0 10px 24px var(--card-shadow); } .plot-card header { padding: 16px 18px 10px; - border-bottom: 1px solid #e2e8f0; + border-bottom: 1px solid var(--header-border); } .plot-card header p { margin-bottom: 0; - color: #475569; + color: var(--muted); font-size: 0.92rem; } - .plot-card .image-wrap { padding: 14px; background: #fff; } + .plot-card .image-wrap { padding: 14px; background: var(--img-bg); } .plot-card img { width: 100%; height: auto; display: block; border-radius: 12px; - background: white; + background: var(--img-bg); } .plot-card footer { padding: 0 18px 16px; font-size: 0.92rem; } - .plot-card a { - color: #2563eb; - text-decoration: none; - font-weight: 600; - } + .plot-card a { color: var(--link); text-decoration: none; font-weight: 600; } .plot-card a:hover { text-decoration: underline; } .links { margin-top: 14px; display: flex; flex-wrap: wrap; gap: 12px; } - .links a { - color: #0369a1; - text-decoration: none; - font-weight: 600; - } + .links a { color: var(--link-alt); text-decoration: none; font-weight: 600; } .links a:hover { text-decoration: underline; } code { font-family: ui-monospace, SFMono-Regular, Menlo, monospace; } + @media (prefers-color-scheme: dark) { + :root:not([data-theme="light"]) { + --bg: #0f172a; --fg: #e2e8f0; + --hero-bg: linear-gradient(135deg, #1e293b, #0f172a 55%, #1a2e1a); + --hero-border: #334155; + --card-bg: #1e293b; --card-border: #334155; --card-shadow: rgba(0,0,0,0.3); + --meta-bg: rgba(30,41,59,0.82); + --muted: #94a3b8; --link: #60a5fa; --link-alt: #38bdf8; + --header-border: #334155; + --img-bg: #1e293b; + } + } + """, + ).strip() + theme_script = dedent( + """ + """, ).strip() distances_text = ", ".join(str(distance) for distance in sorted(set(args.distances))) @@ -2321,6 +2565,7 @@ def plot_card(plot: DashboardPlot) -> list[str]: " ", "", "", + '', "
", '
', "

PECOS Surface Sweep Dashboard

", @@ -2337,7 +2582,7 @@ def plot_card(plot: DashboardPlot) -> list[str]: meta_card("Shots / Point", html.escape(str(args.shots))), meta_card( "Noise Model", - "uniform depolarizing with p1 = p2 = p_meas = p_init = p", + html.escape(_noise_model_description(args)), ), meta_card( "Overall Throughput", @@ -2369,7 +2614,7 @@ def plot_card(plot: DashboardPlot) -> list[str]: parts.extend(plot_card(plot)) parts.extend([" ", "
"]) - parts.extend(["
", "", ""]) + parts.extend(["", theme_script, "", ""]) output_path.write_text("\n".join(parts) + "\n") @@ -2831,7 +3076,7 @@ def _build_report_cover_figure( ("Error Rates", [", ".join(f"{p:.4g}" for p in config.get("error_rates", [])) or "(none)"], 1), ( "Noise Model", - ["uniform depolarizing", "p1 = p2 = p_meas = p_init = p"], + [config.get("noise_model", "depolarizing")], 2, ), ] @@ -3266,6 +3511,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), + "decoder": getattr(args, "decoder", ["pymatching"]), + "noise_model": _noise_model_description(args), "seed": getattr(args, "seed", None), } @@ -3307,6 +3554,26 @@ def _parse_args() -> argparse.Namespace: "default duration window when --duration-multipliers is not provided." ), ) + parser.add_argument( + "--p1-scale", + type=float, + default=1.0 / 30.0, + help=( + "Scale factor for single-qubit gate error rate relative to p. p1 = p * p1_scale. Default: 1/30 (~0.033)." + ), + ) + parser.add_argument( + "--p-meas-scale", + type=float, + default=1.0 / 3.0, + help="Scale factor for measurement error rate. p_meas = p * p_meas_scale. Default: 1/3.", + ) + parser.add_argument( + "--p-prep-scale", + type=float, + default=1.0 / 3.0, + help="Scale factor for preparation error rate. p_prep = p * p_prep_scale. Default: 1/3.", + ) parser.add_argument( "--error-rates", nargs="+", @@ -3344,12 +3611,14 @@ def _parse_args() -> argparse.Namespace: parser.add_argument( "--native-circuit-source", choices=["abstract", "traced_qis"], - default="abstract", + default="traced_qis", help=( "Which ideal circuit the native PECOS DEM/sampler path should analyze. " - "'abstract' uses the existing high-level surface TickCircuit, while " - "'traced_qis' traces the lowered ideal Selene/QIS gate stream and " - "replays that exact circuit into the native PECOS analysis." + "'traced_qis' (default) traces the lowered ideal Selene/QIS gate stream " + "(decomposed into native gates like RZZ+rotations), matching the actual " + "hardware gate set. Use this for hardware-realistic threshold estimation. " + "'abstract' uses the high-level surface TickCircuit with CX/H gates, " + "matching the standard circuit-level noise model from the QEC literature." ), ) parser.add_argument( @@ -3358,6 +3627,28 @@ def _parse_args() -> argparse.Namespace: default="native_decomposed", help="PECOS native DEM mode. PyMatching typically wants native_decomposed.", ) + parser.add_argument( + "--decoder", + nargs="+", + choices=["pymatching", "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. " + "Check-matrix decoders (bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp) " + "extract a check matrix from the DEM automatically." + ), + ) + parser.add_argument( + "--tesseract-beam", + type=int, + default=5, + help=( + "Tesseract det_beam parameter (number of detectors to consider in beam search). " + "Default: 5 (matches upstream). With BFS orderings, det_beam=5 gives identical " + "accuracy to 50 or 100 at d<=5 while being 10x faster." + ), + ) parser.add_argument("--seed", type=int, default=12345, help="Base RNG seed for the runtime noise model.") parser.add_argument("--save-json", action="store_true", help="Write a JSON artifact with all sweep results.") parser.add_argument("--save-svg", action="store_true", help="Write SVG plots for each basis and fitted metric.") @@ -3396,6 +3687,41 @@ def _parse_args() -> argparse.Namespace: default="surface_threshold_sweep", help="Filename prefix for optional artifacts.", ) + parser.add_argument( + "--refine-threshold", + action="store_true", + help=( + "After the initial sweep, estimate the threshold and automatically " + "run a refined sweep with tighter error-rate spacing around it. " + "The refinement uses the same distances, bases, and shots." + ), + ) + parser.add_argument( + "--refine-window", + type=float, + default=0.5, + help=( + "Half-width of the refinement window as a fraction of the estimated " + "threshold. E.g., 0.5 means sweep from 0.5*p_th to 1.5*p_th. " + "Default: 0.5." + ), + ) + parser.add_argument( + "--refine-points", + type=int, + default=6, + help="Number of error-rate points in the refinement sweep. Default: 6.", + ) + parser.add_argument( + "--ancilla-budget", + type=int, + default=None, + help=( + "Optional cap on simultaneously live ancilla qubits. When set, the " + "circuit builder batches stabilizer measurements to stay within this " + "budget. Affects both the abstract and traced_qis circuit sources." + ), + ) parser.add_argument( "--benchmark-repetitions", type=int, @@ -3419,9 +3745,18 @@ def _parse_args() -> argparse.Namespace: } -def _resolve_backends(sample_backend: str) -> list[str]: - """Resolve ``--sample-backend`` to the concrete list of backends to run.""" - return _BACKEND_MODE_EXPANSIONS.get(sample_backend, [sample_backend]) +def _resolve_backends(sample_backend: str, decoders: list[str] | None = None) -> list[str]: + """Resolve ``--sample-backend`` and ``--decoder`` to the concrete list of backends to run. + + When multiple decoders are given, each base backend is expanded to + ``backend:decoder`` pairs so the plotting infrastructure sees them as + separate series. With a single decoder the backend name is unchanged + for backwards compatibility. + """ + base = _BACKEND_MODE_EXPANSIONS.get(sample_backend, [sample_backend]) + if decoders is None or len(decoders) <= 1: + return base + return [f"{b}:{d}" for b in base for d in decoders] def _resolve_duration_schedule( @@ -3512,15 +3847,32 @@ def _print_config_banner( print(f"executed backends: {backends}") print(f"DEM mode : {args.dem_mode}") print(f"native circuit source: {args.native_circuit_source}") - print("decoder : PyMatching via SurfaceDecoder(native PECOS DEM)") + 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)}") - print("noise model : depolarizing with p1 = p2 = p_meas = p_init = p") + 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("fit model : p_L(r) = 0.5 * (1 - (1 - 2 * epsilon) ** r)") if output_dir is not None: print(f"artifact dir : {output_dir}") +def _parse_backend_decoder(backend: str, args: argparse.Namespace) -> tuple[str, str]: + """Split ``backend:decoder`` into base backend and decoder type. + + When a single decoder is configured the backend string has no colon + and the decoder comes from ``args.decoder[0]``. + """ + if ":" in backend: + base, decoder = backend.split(":", 1) + return base, decoder + decoders = getattr(args, "decoder", ["pymatching"]) + return backend, decoders[0] if isinstance(decoders, list) else decoders + + def _run_one_memory_point( args: argparse.Namespace, *, @@ -3532,9 +3884,10 @@ def _run_one_memory_point( seed: int, ) -> tuple[SweepPoint, dict[str, Any]]: """Run one sampling point, print the per-point line, return the point + its timing row.""" + base_backend, decoder_type = _parse_backend_decoder(backend, args) point_start = time.perf_counter() point = _run_memory_point( - sample_backend=backend, + sample_backend=base_backend, distance=distance, basis=basis, physical_error_rate=physical_error_rate, @@ -3543,6 +3896,12 @@ def _run_one_memory_point( dem_mode=args.dem_mode, native_circuit_source=args.native_circuit_source, seed=seed, + decoder_type=decoder_type, + backend_label=backend, + ancilla_budget=getattr(args, "ancilla_budget", None), + 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), ) elapsed_seconds = time.perf_counter() - point_start naive_per_round = ler_per_round_exp(point.logical_error_rate, point.total_rounds) @@ -3784,15 +4143,31 @@ def _print_post_sweep_analysis( f"log_rmse={fit.fit_root_mean_square_log_error:.3e}", ) - crossing = _estimate_threshold(basis_summaries) + crossing_per_round = _estimate_threshold( + basis_summaries, + metric="fitted_logical_error_rate_per_round", + ) + crossing_d_rounds = _estimate_threshold( + basis_summaries, + metric="fitted_projected_logical_error_rate_over_d_rounds", + ) global_scaling_fit = _fit_global_scaling_law(basis_summaries) - fss_fit = _fit_fss_threshold(basis_summaries, seed_threshold=crossing) - if crossing is not None or global_scaling_fit is not None or fss_fit is not None: + # Try FSS fit seeded from both crossings; prefer d-round seed + fss_seed = crossing_d_rounds or crossing_per_round + fss_fit = _fit_fss_threshold(basis_summaries, seed_threshold=fss_seed) + if ( + crossing_per_round is not None + or crossing_d_rounds is not None + or global_scaling_fit is not None + or fss_fit is not None + ): print(f"{basis} basis [{backend}] background threshold-style summary:") - if crossing is None: + if crossing_per_round is None and crossing_d_rounds is None: print(f" no d={min(distances)} vs d={max(distances)} crossing was detected on this sweep.") - else: - print(f" approximate threshold crossing from fitted d-round curves: p ~= {crossing:.6g}") + if crossing_per_round is not None: + print(f" per-round epsilon crossing: p ~= {crossing_per_round:.6g}") + if crossing_d_rounds is not None: + print(f" projected d-round crossing: p ~= {crossing_d_rounds:.6g}") if global_scaling_fit is not None: print( " " @@ -3830,7 +4205,7 @@ def main() -> int: distances = sorted(set(args.distances)) bases = [basis.upper() for basis in args.bases] - backends = _resolve_backends(args.sample_backend) + backends = _resolve_backends(args.sample_backend, args.decoder) duration_multipliers, duration_rounds_by_distance, duration_schedule_description = _resolve_duration_schedule( args, distances, @@ -3884,6 +4259,71 @@ def main() -> int: fit_summaries=fit_summaries, ) + # --- Adaptive threshold refinement --- + if args.refine_threshold: + # Estimate threshold from the initial sweep + threshold_estimates = [] + for basis in bases: + basis_summaries = [s for s in fit_summaries if s.basis == basis] + for backend in backends: + backend_summaries = [s for s in basis_summaries if s.backend == backend] + if not backend_summaries: + continue + # Use d-round crossing as initial estimate (more conservative) + crossing = _estimate_threshold( + backend_summaries, + metric="fitted_projected_logical_error_rate_over_d_rounds", + ) + if crossing is None: + # Fall back to per-round crossing + crossing = _estimate_threshold(backend_summaries) + if crossing is not None: + threshold_estimates.append((backend, basis, crossing)) + + if threshold_estimates: + # Use the median estimate across all backends/bases + median_th = sorted(t[2] for t in threshold_estimates)[len(threshold_estimates) // 2] + half_w = args.refine_window + p_low = median_th * (1.0 - half_w) + p_high = median_th * (1.0 + half_w) + n_pts = args.refine_points + import numpy as np + + refined_rates = sorted({float(f"{r:.6g}") for r in np.linspace(p_low, p_high, n_pts)}) + # Exclude rates already in the initial sweep + refined_rates = [r for r in refined_rates if r not in error_rates and r > 0] + + if refined_rates: + print() + print( + f"=== Threshold refinement: {len(refined_rates)} additional points " + f"in [{p_low:.5g}, {p_high:.5g}] around estimate p_th ~= {median_th:.5g} ===", + ) + refine_points, refine_fits, refine_timings = _run_sweep_and_fit( + args, + backends=backends, + distances=distances, + bases=bases, + error_rates=refined_rates, + duration_rounds_by_distance=duration_rounds_by_distance, + ) + # Merge with initial results + all_points.extend(refine_points) + fit_summaries.extend(refine_fits) + point_timings.extend(refine_timings) + + # Re-run analysis with merged data + print() + print("=== Combined analysis (initial + refinement) ===") + _print_post_sweep_analysis( + backends=backends, + bases=bases, + distances=distances, + fit_summaries=fit_summaries, + ) + else: + print("\n No threshold detected -- skipping refinement.") + timing_summary = _timing_summary( point_timings, total_wall_clock_seconds=time.perf_counter() - sweep_start, diff --git a/examples/surface/run_full_sweep.sh b/examples/surface/run_full_sweep.sh new file mode 100755 index 000000000..10757627d --- /dev/null +++ b/examples/surface/run_full_sweep.sh @@ -0,0 +1,117 @@ +#!/bin/bash +# Run all data generation shards sequentially, then analyze and build reports. +# +# Usage: +# bash examples/surface/run_full_sweep.sh +# bash examples/surface/run_full_sweep.sh --output-dir ~/Repos/pecos-data/reports/surface-code-decoder-comparison +# +# Each shard is skipped if its output already exists (re-run safe). +# Delete a shard's JSON to regenerate it. + +set -euo pipefail + +OUTPUT_DIR="${1:---output-dir}" +if [ "$OUTPUT_DIR" = "--output-dir" ]; then + OUTPUT_DIR="${2:-/tmp/pecos_sweep}" +fi + +DATA_DIR="$OUTPUT_DIR/data" +mkdir -p "$DATA_DIR" + +GEN="uv run python examples/surface/generate_data.py" + +# Common error rates +LOW_P="0.0004 0.0008 0.001 0.0015" +MID_P="0.002 0.004 0.006 0.008 0.010 0.012 0.014" +HIGH_P="0.016 0.018 0.020" +ALL_P="$LOW_P $MID_P $HIGH_P" + +MULTI_ROUNDS="2.0 2.33 2.67 3.0" + +run_shard() { + local name="$1" + shift + local outdir="$DATA_DIR/$name" + if [ -f "$outdir/data.json" ]; then + echo "=== SKIP $name (already exists) ===" + return + fi + echo "" + echo "=== $name ===" + echo "" + $GEN "$@" --output-dir "$outdir" +} + +# --- PyMatching: all distances, all error rates, multi-round --- +run_shard pm_all_d3579 \ + --distances 3 5 7 9 \ + --error-rates $ALL_P \ + --decoders pymatching \ + --shots 5000 \ + --duration-multipliers $MULTI_ROUNDS \ + --seed 42 + +# Extra shots at very low p for PyMatching (need more to resolve rare errors) +run_shard pm_lowp_extra \ + --distances 3 5 7 9 \ + --error-rates $LOW_P \ + --decoders pymatching \ + --shots 15000 \ + --duration-multipliers $MULTI_ROUNDS \ + --seed 142 + +# --- Tesseract: d=3,5,7 multi-round, d=9 single round --- +run_shard ts_d357 \ + --distances 3 5 7 \ + --error-rates $MID_P $HIGH_P \ + --decoders tesseract \ + --shots 5000 \ + --duration-multipliers $MULTI_ROUNDS \ + --seed 200 + +run_shard ts_d9 \ + --distances 9 \ + --error-rates $MID_P \ + --decoders tesseract \ + --shots 5000 \ + --duration-multipliers 2.0 \ + --seed 300 + +# --- MWPF: d=3,5,7 single round --- +run_shard mwpf_d357 \ + --distances 3 5 7 \ + --error-rates $MID_P $HIGH_P \ + --decoders mwpf \ + --shots 5000 \ + --duration-multipliers 2.0 \ + --seed 400 + +# --- BP+OSD: d=3,5,7 single round --- +run_shard bposd_d357 \ + --distances 3 5 7 \ + --error-rates $MID_P $HIGH_P \ + --decoders bp_osd \ + --shots 5000 \ + --duration-multipliers 2.0 \ + --seed 500 + +# --- Analyze --- +echo "" +echo "=== ANALYZE ===" +echo "" +uv run python examples/surface/analyze_data.py \ + "$DATA_DIR"/*/data.json \ + -o "$OUTPUT_DIR" + +# --- Build reports --- +echo "" +echo "=== BUILD REPORTS ===" +echo "" +uv run python examples/surface/build_report.py \ + "$OUTPUT_DIR/analysis.json" \ + --html --pdf --markdown \ + -o "$OUTPUT_DIR" + +echo "" +echo "Done. Reports in $OUTPUT_DIR/" +ls -lh "$OUTPUT_DIR"/report.* diff --git a/examples/surface/validate_dem_correlations.py b/examples/surface/validate_dem_correlations.py new file mode 100644 index 000000000..78f84ed9e --- /dev/null +++ b/examples/surface/validate_dem_correlations.py @@ -0,0 +1,307 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Validate DEM detector correlations against simulation ground truth. + +Computes per-round detector flip frequency matrices from both simulation +and DEM sampling, then compares them element-wise. The matrix diagonal +gives marginal detection rates; off-diagonal elements give half the +joint detection probability, capturing the correlated error structure. + +Usage: + uv run python examples/surface/validate_dem_correlations.py + uv run python examples/surface/validate_dem_correlations.py -d 3 5 --shots 100000 + uv run python examples/surface/validate_dem_correlations.py --circuit-source traced_qis + uv run python examples/surface/validate_dem_correlations.py --show-matrices +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time + + +def build_circuit(distance, rounds, basis, circuit_source): + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=distance) + + if circuit_source == "traced_qis": + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + tc = _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source="traced_qis", + ) + tc.lower_clifford_rotations() + tc.assign_missing_meas_ids() + else: + from pecos.qec.surface import LogicalCircuitBuilder + + b = LogicalCircuitBuilder() + b.add_patch(patch, "Q0") + b.add_memory("Q0", rounds=rounds, basis=basis) + tc = b.to_tick_circuit() + + return tc, patch + + +def simulate_detector_events(tc, noise_kw, shots, seed): + """Run simulation and extract per-shot detector event lists.""" + from pecos_rslib_exp import depolarizing, sim_neo, stabilizer + + noise = depolarizing() + for k, v in noise_kw.items(): + if v > 0: + noise = getattr(noise, k)(v) + + det_json = json.loads(tc.get_meta("detectors")) + num_meas = int(tc.get_meta("num_measurements")) + num_dets = len(det_json) + + results = sim_neo(tc).quantum(stabilizer()).noise(noise).shots(shots).seed(seed).run() + + events = [] + for r in results: + meas = list(r) + fired = [] + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + fired.append(i) + events.append(fired) + + return events, num_dets + + +def dem_detector_events(tc, noise_kw, shots, seed): + """Sample from DEM and extract per-shot detector event lists.""" + from pecos_rslib.qec import DemSampler + + full_kw = {k: noise_kw.get(k, 0.0) for k in ["p1", "p2", "p_meas", "p_prep"]} + sampler = DemSampler.from_circuit(tc, **full_kw) + batch = sampler.generate_samples(num_shots=shots, seed=seed) + num_dets = len(json.loads(tc.get_meta("detectors"))) + + events = [] + for i in range(shots): + syn = batch.get_syndrome(i) + fired = [d for d in range(min(num_dets, len(syn))) if syn[d]] + events.append(fired) + + return events, num_dets + + +def format_matrix(matrix, width=8, precision=5): + """Format a matrix as an aligned string.""" + lines = [] + for row in matrix: + cells = [f"{v:{width}.{precision}f}" for v in row] + lines.append("[" + " ".join(cells) + "]") + return "\n".join(lines) + + +def run_validation( + *, + distances, + bases, + rounds_per_d, + shots, + seed, + circuit_sources, + noise_configs, + threshold, + show_matrices, + max_order, +): + from pecos.qec.analysis import ( + compare_flip_matrices, + compare_k_body_rates, + detector_flip_matrices_by_round, + detector_k_body_rates_by_round, + ) + + total_pass = 0 + total_fail = 0 + failures = [] + + for distance in distances: + rounds = rounds_per_d if rounds_per_d else distance + for basis in bases: + for source in circuit_sources: + tc, patch = build_circuit(distance, rounds, basis, source) + num_dets = len(json.loads(tc.get_meta("detectors"))) + n_ancilla = len(patch.x_stabilizers) + len(patch.z_stabilizers) + + src_label = f" [{source}]" if len(circuit_sources) > 1 else "" + print(f"\n{'=' * 72}") + print( + f"d={distance} {basis}-basis{src_label}, {num_dets} detectors, " + f"{n_ancilla} per round, {rounds} rounds", + ) + print(f"{'=' * 72}") + + for noise_label, noise_kw in noise_configs: + t0 = time.perf_counter() + sim_events, nd = simulate_detector_events(tc, noise_kw, shots, seed) + sim_time = time.perf_counter() - t0 + + dem_events, _ = dem_detector_events(tc, noise_kw, shots, seed) + + # --- Pairwise flip matrices --- + sim_mats = detector_flip_matrices_by_round(sim_events, nd, n_ancilla) + dem_mats = detector_flip_matrices_by_round(dem_events, nd, n_ancilla) + + all_pass = True + round_results = [] + for r_idx, (sm, dm) in enumerate(zip(sim_mats, dem_mats, strict=False)): + max_err, frob_err, worst = compare_flip_matrices(sm, dm) + ok = max_err <= threshold + if not ok: + all_pass = False + round_results.append((r_idx, max_err, frob_err, worst, ok)) + + # --- Higher-order correlations --- + sim_kbody = detector_k_body_rates_by_round( + sim_events, + nd, + n_ancilla, + max_order=max_order, + ) + dem_kbody = detector_k_body_rates_by_round( + dem_events, + nd, + n_ancilla, + max_order=max_order, + ) + + kbody_results = [] # (round, order, max_err, rms_err, worst, ok) + for r_idx, (sr, dr) in enumerate(zip(sim_kbody, dem_kbody, strict=False)): + order_stats = compare_k_body_rates(sr, dr, max_order=max_order) + for order, (me, rms, worst_ev) in order_stats.items(): + ok = me <= threshold + if not ok: + all_pass = False + kbody_results.append((r_idx, order, me, rms, worst_ev, ok)) + + # Aggregate + worst_round_err = max(rr[1] for rr in round_results) + status = "PASS" if all_pass else "FAIL" + if all_pass: + total_pass += 1 + else: + total_fail += 1 + failures.append( + f"d={distance} {basis} {source} {noise_label}: {worst_round_err * 100:.0f}%", + ) + + print(f"\n {noise_label} (sim: {sim_time:.2f}s) {status}") + + # Pairwise per-round summary + print(" Pairwise (flip matrices):") + for r_idx, max_err, frob_err, worst, ok in round_results: + flag = "" if ok else " <-- FAIL" + print( + f" Round {r_idx}: max_rel={max_err * 100:5.1f}% " + f"frob_rel={frob_err * 100:5.1f}% " + f"worst={worst}{flag}", + ) + + # Higher-order per-round summary + for order in range(1, max_order + 1): + order_entries = [(r, me, rms, w, ok) for r, o, me, rms, w, ok in kbody_results if o == order] + if not order_entries: + continue + worst_me = max(e[1] for e in order_entries) + avg_rms = sum(e[2] for e in order_entries) / len(order_entries) + label = { + 1: "1-body (marginals)", + 2: "2-body (pairs)", + 3: "3-body (triples)", + 4: "4-body (quads)", + }.get(order, f"{order}-body") + any_fail = any(not e[4] for e in order_entries) + flag = " <-- FAIL" if any_fail else "" + print( + f" {label}: worst_max_rel={worst_me * 100:5.1f}% " + f"avg_rms_rel={avg_rms * 100:5.1f}%{flag}", + ) + if any_fail: + for r, me, _rms, w, ok in order_entries: + if not ok: + print(f" Round {r}: max_rel={me * 100:.1f}% worst={w}") + + if show_matrices and not all_pass: + for r_idx, _max_err, _, _, ok in round_results: + if not ok: + print(f"\n Round {r_idx} sim:") + print(" " + format_matrix(sim_mats[r_idx]).replace("\n", "\n ")) + print(f" Round {r_idx} dem:") + print(" " + format_matrix(dem_mats[r_idx]).replace("\n", "\n ")) + + print(f"\n{'=' * 72}") + print(f"SUMMARY: {total_pass}/{total_pass + total_fail} passed (threshold: {threshold * 100:.0f}%)") + if failures: + print("Failures:") + for f in failures: + print(f" {f}") + print(f"{'=' * 72}") + + return total_fail == 0 + + +def main(): + parser = argparse.ArgumentParser( + description="Validate DEM detector correlations against simulation.", + ) + parser.add_argument("--distance", "-d", type=int, nargs="+", default=[2, 3], help="Code distances (default: 2 3)") + parser.add_argument("--basis", type=str, nargs="+", default=["Z"], choices=["Z", "X"], help="Bases (default: Z)") + parser.add_argument("--rounds", type=int, default=None, help="Syndrome rounds (default: same as distance)") + parser.add_argument("--shots", type=int, default=100000, help="Shots per test (default: 100000)") + parser.add_argument("--seed", type=int, default=42) + parser.add_argument( + "--circuit-source", + choices=["abstract", "traced_qis", "both"], + default="both", + help="Circuit pipeline (default: both)", + ) + parser.add_argument("--threshold", type=float, default=0.20, help="Max relative error threshold (default: 0.20)") + parser.add_argument("--max-order", type=int, default=3, help="Max correlation order (default: 3)") + parser.add_argument("--show-matrices", action="store_true", help="Print matrices for failing rounds") + + args = parser.parse_args() + + sources = ["abstract", "traced_qis"] if args.circuit_source == "both" else [args.circuit_source] + + noise_configs = [ + ("p_meas=0.01", {"p1": 0.0, "p2": 0.0, "p_meas": 0.01, "p_prep": 0.0}), + ("p2=0.01", {"p1": 0.0, "p2": 0.01, "p_meas": 0.0, "p_prep": 0.0}), + ("depol", {"p1": 0.001, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01}), + ("strong_depol", {"p1": 0.005, "p2": 0.05, "p_meas": 0.05, "p_prep": 0.05}), + ] + + ok = run_validation( + distances=args.distance, + bases=args.basis, + rounds_per_d=args.rounds, + shots=args.shots, + seed=args.seed, + circuit_sources=sources, + noise_configs=noise_configs, + threshold=args.threshold, + show_matrices=args.show_matrices, + max_order=args.max_order, + ) + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/examples/surface/validate_dem_generators.py b/examples/surface/validate_dem_generators.py new file mode 100644 index 000000000..66efd020a --- /dev/null +++ b/examples/surface/validate_dem_generators.py @@ -0,0 +1,396 @@ +r"""Validate ALL DEM generators against stabilizer() ground truth. + +Systematically tests each DEM generation path across: +- Multiple distances (d=2, 3) +- Both bases (Z, X) +- Each noise component independently + combined +- All DEM generators (DemBuilder, from_circuit, exact_detection_rates, perturbative_dem) + +Uses sim_neo().quantum(stabilizer()) as ground truth for depolarizing noise. + +Example: + uv run python examples/surface/validate_dem_generators.py + uv run python examples/surface/validate_dem_generators.py --shots 50000 + uv run python examples/surface/validate_dem_generators.py -d 2 --verbose + uv run python examples/surface/validate_dem_generators.py --circuit-source both + uv run python examples/surface/validate_dem_generators.py --circuit-source traced_qis +""" + +from __future__ import annotations + +import argparse +import json +import sys +import time +from pathlib import Path + +sys.path.insert(0, str(Path(__file__).resolve().parents[2] / "python" / "quantum-pecos" / "src")) + +# Noise configurations: each component independently + combined. +# "ground_truth" specifies which simulator to use: +# "stabilizer" for depolarizing (exact, any distance) +# "statevec" for coherent noise (exact, limited to small circuits) +NOISE_CONFIGS = [ + # Depolarizing components (ground truth: stabilizer) + ("p_meas only", {"p_meas": 0.01}, "stabilizer"), + ("p_prep only", {"p_prep": 0.01}, "stabilizer"), + ("p1 only", {"p1": 0.01}, "stabilizer"), + ("p2 only", {"p2": 0.01}, "stabilizer"), + ("depol all", {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005}, "stabilizer"), + ("depol strong", {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01}, "stabilizer"), +] + +# Coherent noise configs (ground truth: statevec, small circuits only) +COHERENT_CONFIGS = [ + ("idle_rz only", {"idle_rz": 0.05}, "statevec"), + ("rz+depol", {"idle_rz": 0.05, "p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005}, "statevec"), +] + +# Threshold for pass/fail (relative error vs stabilizer) +THRESHOLD = 0.15 # 15% — accounts for statistical noise at moderate shot counts + + +def build_circuit(distance, rounds, basis, circuit_source="abstract", *, fill_idle=False): + """Build surface code TickCircuit. + + circuit_source: + "abstract" — LogicalCircuitBuilder (direct gate construction) + "traced_qis" — Guppy → Selene → QIS trace → TickCircuit + fill_idle: + Insert Idle(1) gates on inactive qubits each tick. Needed for + realistic idle_rz noise (RZ applied when Idle gate is seen). + """ + from pecos.qec.surface import SurfacePatch + + patch = SurfacePatch.create(distance=distance) + + if circuit_source == "traced_qis": + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + tc = _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source="traced_qis", + ) + # Compilation passes for traced QIS circuits: + tc.lower_clifford_rotations() # RZ(pi/2) -> SZ, etc. + tc.assign_missing_meas_ids() # Stamp MeasId on MZ gates + else: + from pecos.qec.surface import LogicalCircuitBuilder + + b = LogicalCircuitBuilder() + b.add_patch(patch, "Q0") + b.add_memory("Q0", rounds=rounds, basis=basis) + tc = b.to_tick_circuit() + + # Optional passes applied to all circuits: + if fill_idle: + # Insert Idle(1) after 2q gates (for idle_rz noise modeling) + tc.insert_idle_after_two_qubit_gates(1.0) + # Fill remaining inactive qubits with Idle gates + tc.fill_idle_gates() + + return tc + + +def ground_truth_rates(tc, noise_kw, shots, seed, det_json, num_meas, num_dets, simulator="stabilizer"): + """Ground truth: simulation with detector extraction.""" + from pecos_rslib_exp import depolarizing, sim_neo, stabilizer, statevec + + noise = depolarizing() + for k, v in noise_kw.items(): + noise = getattr(noise, k)(v) + + backend = stabilizer() if simulator == "stabilizer" else statevec() + results = sim_neo(tc).quantum(backend).noise(noise).shots(shots).seed(seed).run() + rates = [0.0] * num_dets + for r in results: + meas = list(r) + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + rates[i] += 1.0 / shots + return rates + + +def full_noise_kw(noise_kw): + """Ensure all noise params are present (default 0 for missing).""" + result = { + "p1": noise_kw.get("p1", 0.0), + "p2": noise_kw.get("p2", 0.0), + "p_meas": noise_kw.get("p_meas", 0.0), + "p_prep": noise_kw.get("p_prep", 0.0), + } + if "idle_rz" in noise_kw: + result["idle_rz"] = noise_kw["idle_rz"] + return result + + +def dem_sampler_rates(tc, noise_kw, shots, seed, num_dets): + """DemSampler.from_circuit path.""" + from pecos_rslib.qec import DemSampler + + sampler = DemSampler.from_circuit(tc, **full_noise_kw(noise_kw)) + batch = sampler.generate_samples(num_shots=shots, seed=seed) + rates = [0.0] * num_dets + for i in range(shots): + syn = batch.get_syndrome(i) + for d in range(min(num_dets, len(syn))): + if syn[d]: + rates[d] += 1.0 / shots + return rates + + +def dem_builder_rates(tc, noise_kw, shots, seed, num_dets): + """DemBuilder path (explicit, uses .to_sampler()).""" + from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder + + dag = tc.to_dag_circuit() + analyzer = DagFaultAnalyzer(dag) + influence = analyzer.build_influence_map() + dem = ( + DemBuilder(influence) + .with_noise(**full_noise_kw(noise_kw)) + .with_detectors_json(tc.get_meta("detectors")) + .with_observables_json(tc.get_meta("observables")) + .with_num_measurements(int(tc.get_meta("num_measurements"))) + .build() + ) + sampler = dem.to_sampler() + batch = sampler.generate_samples(num_shots=shots, seed=seed) + rates = [0.0] * num_dets + for i in range(shots): + syn = batch.get_syndrome(i) + for d in range(min(num_dets, len(syn))): + if syn[d]: + rates[d] += 1.0 / shots + return rates + + +def heisenberg_rates(tc, noise_kw, num_dets): + """Backward Heisenberg exact detection rates.""" + from pecos_rslib_exp import exact_detection_rates + + results = exact_detection_rates(tc, **full_noise_kw(noise_kw)) + rates = [0.0] * num_dets + for det_id, prob in results: + if det_id < num_dets: + rates[det_id] = prob + return rates + + +def perturbative_rates(tc, noise_kw, num_dets): + """Forward EEG perturbative marginals.""" + from pecos_rslib_exp import perturbative_dem_events + + events = perturbative_dem_events(tc, **full_noise_kw(noise_kw)) + rates = [0.0] * num_dets + for prob, dets, _obs in events: + for d in dets: + if d < num_dets: + rates[d] += prob + return rates + + +def max_rel_error(test_rates, ref_rates, min_rate=0.003): + """Max relative error across detectors with rate > min_rate.""" + max_err = 0.0 + for t, r in zip(test_rates, ref_rates, strict=False): + if r > min_rate: + max_err = max(max_err, abs(t / r - 1)) + return max_err + + +def run_validation(*, distances, bases, shots, seed, verbose, circuit_sources, fill_idle): + total_tests = 0 + total_pass = 0 + total_fail = 0 + failures = [] + + # Generators: (name, func, supports_coherent) + # from_circuit and DemBuilder only support depolarizing (no idle_rz) + generators = [ + ("from_circuit", dem_sampler_rates, False), + ("DemBuilder", dem_builder_rates, False), + ("Heisenberg", None, True), + ("Perturbative", None, True), + ] + + for distance in distances: + for basis in bases: + for circuit_source in circuit_sources: + try: + tc = build_circuit(distance, distance, basis, circuit_source, fill_idle=fill_idle) + except Exception as e: + print(f"\n d={distance} {basis} [{circuit_source}]: SKIP ({e})") + continue + + det_json = json.loads(tc.get_meta("detectors")) + num_meas = int(tc.get_meta("num_measurements")) + num_dets = len(det_json) + + src_label = f" [{circuit_source}]" if len(circuit_sources) > 1 else "" + print(f"\n{'='*72}") + print(f"d={distance} {basis}-basis{src_label}, {num_dets} detectors, {num_meas} measurements") + print(f"{'='*72}") + + # Combine depolarizing + coherent configs + all_configs = list(NOISE_CONFIGS) + list(COHERENT_CONFIGS) + + for noise_label, noise_kw, gt_simulator in all_configs: + is_coherent = "idle_rz" in noise_kw + + # Ground truth + t0 = time.perf_counter() + try: + ref = ground_truth_rates( + tc, + noise_kw, + shots, + seed, + det_json, + num_meas, + num_dets, + simulator=gt_simulator, + ) + except BaseException as e: + if verbose: + print(f"\n {noise_label}: SKIP ground truth ({type(e).__name__}: {e})") + continue + ref_time = time.perf_counter() - t0 + + if verbose: + print(f"\n {noise_label} ({gt_simulator}: {ref_time:.2f}s)") + + for gen_name, gen_func, supports_coherent in generators: + # Skip non-EEG generators for coherent noise + if is_coherent and not supports_coherent: + if verbose: + print(f" {gen_name:<14} (skipped: no coherent support)") + continue + t0 = time.perf_counter() + try: + if gen_name == "Heisenberg": + test = heisenberg_rates(tc, noise_kw, num_dets) + elif gen_name == "Perturbative": + test = perturbative_rates(tc, noise_kw, num_dets) + else: + test = gen_func(tc, noise_kw, shots, seed, num_dets) + dt = time.perf_counter() - t0 + + err = max_rel_error(test, ref) + ok = err < THRESHOLD + total_tests += 1 + if ok: + total_pass += 1 + else: + total_fail += 1 + failures.append( + f"d={distance} {basis} {noise_label} {gen_name}: {err*100:.0f}%", + ) + + status = "PASS" if ok else f"FAIL({err*100:.0f}%)" + if verbose: + print(f" {gen_name:<14} {dt*1000:>7.0f}ms {status}") + elif not ok: + print(f" {noise_label:<14} {gen_name:<14} {status}") + + except Exception as e: + total_tests += 1 + total_fail += 1 + failures.append( + f"d={distance} {basis} {noise_label} {gen_name}: ERROR {e}", + ) + if verbose: + print(f" {gen_name:<14} ERROR: {e}") + + if not verbose: + # Print summary for this config + pass + + # Final summary + print(f"\n{'='*72}") + print(f"VALIDATION SUMMARY: {total_pass}/{total_tests} passed, {total_fail} failed") + print(f"Threshold: {THRESHOLD*100:.0f}% relative error ({shots} shots)") + if failures: + print("\nFailures:") + for f in failures: + print(f" {f}") + else: + print("\nAll tests passed.") + print(f"{'='*72}") + + return total_fail == 0 + + +def main(): + parser = argparse.ArgumentParser( + description=__doc__, + formatter_class=argparse.RawDescriptionHelpFormatter, + ) + parser.add_argument( + "--distance", + "-d", + type=int, + nargs="+", + default=[2, 3], + help="Code distances to test (default: 2 3)", + ) + parser.add_argument( + "--basis", + choices=["X", "Z"], + nargs="+", + default=["Z", "X"], + help="Bases to test (default: Z X)", + ) + parser.add_argument( + "--shots", + type=int, + default=20000, + help="Shots per test (default: 20000)", + ) + parser.add_argument( + "--seed", + type=int, + default=42, + ) + parser.add_argument( + "--verbose", + "-v", + action="store_true", + help="Show per-generator timing and results", + ) + parser.add_argument( + "--circuit-source", + choices=["abstract", "traced_qis", "both"], + default="abstract", + help="Circuit construction pipeline (default: abstract)", + ) + parser.add_argument( + "--fill-idle", + action="store_true", + help="Insert Idle(1) gates on inactive qubits (needed for idle_rz noise)", + ) + args = parser.parse_args() + + sources = ["abstract", "traced_qis"] if args.circuit_source == "both" else [args.circuit_source] + + ok = run_validation( + distances=args.distance, + bases=args.basis, + shots=args.shots, + seed=args.seed, + verbose=args.verbose, + circuit_sources=sources, + fill_idle=args.fill_idle, + ) + sys.exit(0 if ok else 1) + + +if __name__ == "__main__": + main() diff --git a/examples/surface_code_experiments.ipynb b/examples/surface_code_experiments.ipynb index 43a38bdbd..40d1ff49f 100644 --- a/examples/surface_code_experiments.ipynb +++ b/examples/surface_code_experiments.ipynb @@ -75,7 +75,7 @@ "NUM_SHOTS = 1000\n", "DECODER_TYPES = [\"pymatching\", \"fusion_blossom\"]\n", "\n", - "# Uniform depolarizing noise: p1 = p2 = p_meas = p_init = p\n", + "# Uniform depolarizing noise: p1 = p2 = p_meas = p_prep = p\n", "ERROR_RATES = [0.001, 0.002, 0.005, 0.008, 0.01]" ] }, @@ -542,7 +542,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_init=p)\n", + " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " # Simulate once, decode with all decoders\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", @@ -744,7 +744,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_init=p)\n", + " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", "\n", diff --git a/examples/surface_code_noisy_decoding.ipynb b/examples/surface_code_noisy_decoding.ipynb index cabb4a10c..330d661d7 100644 --- a/examples/surface_code_noisy_decoding.ipynb +++ b/examples/surface_code_noisy_decoding.ipynb @@ -101,7 +101,7 @@ } }, "outputs": [], - "source": "from typing import Any\n\n\ndef get_logical_qubits(distance: int, basis: str) -> tuple:\n \"\"\"Get qubits in the logical operator.\"\"\"\n patch = SurfacePatch.create(distance=distance)\n if basis == \"Z\":\n return patch.geometry.logical_z.data_qubits\n return patch.geometry.logical_x.data_qubits\n\n\ndef run_memory_experiment(\n distance: int,\n num_rounds: int,\n num_shots: int,\n basis: str,\n error_model: Any,\n *,\n decode: bool = False,\n decoder_type: str = \"pymatching\",\n) -> dict:\n \"\"\"Run memory experiment and compute logical error rate.\n\n For Z-basis: prepare |0_L>, measure in Z basis, check logical Z parity.\n For X-basis: prepare |+_L>, measure in X basis, check logical X parity.\n\n Args:\n distance: Code distance\n num_rounds: Number of syndrome extraction rounds\n num_shots: Number of shots to run\n basis: 'Z' or 'X' basis\n error_model: Selene error model (IdealErrorModel or DepolarizingErrorModel)\n decode: If True, use decoding to correct errors\n decoder_type: Decoder backend ('pymatching', 'fusion_blossom', 'bp_osd', 'bp_lsd', 'union_find', 'tesseract')\n\n Returns:\n Dictionary with experiment results\n \"\"\"\n patch = SurfacePatch.create(distance=distance)\n logical_qubits = get_logical_qubits(distance, basis)\n\n # Create decoder if needed\n decoder = None\n if decode:\n # Extract noise parameters from error model\n noise = NoiseModel(\n p1=getattr(error_model, \"p_1q\", 0.01),\n p2=getattr(error_model, \"p_2q\", 0.01),\n p_meas=getattr(error_model, \"p_meas\", 0.01),\n p_init=getattr(error_model, \"p_init\", 0.01),\n )\n decoder = SurfaceDecoder(patch, num_rounds=num_rounds, noise=noise, decoder_type=decoder_type)\n\n # Build circuit\n num_qubits = get_num_qubits(distance)\n prog = make_surface_code(distance=distance, num_rounds=num_rounds, basis=basis)\n hugr_bytes = compile_guppy_to_hugr(prog)\n instance = build(hugr_bytes, name=f\"surface_d{distance}\")\n\n # Run\n num_logical_errors = 0\n num_raw_errors = 0\n\n for shot_results in instance.run_shots(\n simulator=Stim(),\n n_qubits=num_qubits,\n n_shots=num_shots,\n error_model=error_model,\n runtime=SimpleRuntime(),\n n_processes=1,\n ):\n # Collect all syndromes properly (multiple entries per key)\n synx_list = []\n synz_list = []\n final = None\n\n for name, values in shot_results:\n vals = list(values)\n if name == \"synx\":\n synx_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"synz\":\n synz_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"final\":\n final = vals\n\n if final is None:\n continue\n\n # Raw parity check (no decoding)\n raw_parity = sum(final[q] for q in logical_qubits) % 2\n if raw_parity != 0:\n num_raw_errors += 1\n\n if decode and decoder is not None:\n final_arr = np.array(final, dtype=np.uint8)\n\n # Decode based on basis\n if basis == \"Z\":\n is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final_arr)\n else:\n is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final_arr)\n\n if is_error:\n num_logical_errors += 1\n else:\n # No decoding - use raw parity\n if raw_parity != 0:\n num_logical_errors += 1\n\n return {\n \"distance\": distance,\n \"num_shots\": num_shots,\n \"num_logical_errors\": num_logical_errors,\n \"num_raw_errors\": num_raw_errors,\n \"logical_error_rate\": num_logical_errors / num_shots,\n \"raw_error_rate\": num_raw_errors / num_shots,\n \"decoded\": decode,\n \"decoder_type\": decoder_type if decode else None,\n }" + "source": "from typing import Any\n\n\ndef get_logical_qubits(distance: int, basis: str) -> tuple:\n \"\"\"Get qubits in the logical operator.\"\"\"\n patch = SurfacePatch.create(distance=distance)\n if basis == \"Z\":\n return patch.geometry.logical_z.data_qubits\n return patch.geometry.logical_x.data_qubits\n\n\ndef run_memory_experiment(\n distance: int,\n num_rounds: int,\n num_shots: int,\n basis: str,\n error_model: Any,\n *,\n decode: bool = False,\n decoder_type: str = \"pymatching\",\n) -> dict:\n \"\"\"Run memory experiment and compute logical error rate.\n\n For Z-basis: prepare |0_L>, measure in Z basis, check logical Z parity.\n For X-basis: prepare |+_L>, measure in X basis, check logical X parity.\n\n Args:\n distance: Code distance\n num_rounds: Number of syndrome extraction rounds\n num_shots: Number of shots to run\n basis: 'Z' or 'X' basis\n error_model: Selene error model (IdealErrorModel or DepolarizingErrorModel)\n decode: If True, use decoding to correct errors\n decoder_type: Decoder backend ('pymatching', 'fusion_blossom', 'bp_osd', 'bp_lsd', 'union_find', 'tesseract')\n\n Returns:\n Dictionary with experiment results\n \"\"\"\n patch = SurfacePatch.create(distance=distance)\n logical_qubits = get_logical_qubits(distance, basis)\n\n # Create decoder if needed\n decoder = None\n if decode:\n # Extract noise parameters from error model\n noise = NoiseModel(\n p1=getattr(error_model, \"p_1q\", 0.01),\n p2=getattr(error_model, \"p_2q\", 0.01),\n p_meas=getattr(error_model, \"p_meas\", 0.01),\n p_prep=getattr(error_model, \"p_init\", 0.01),\n )\n decoder = SurfaceDecoder(patch, num_rounds=num_rounds, noise=noise, decoder_type=decoder_type)\n\n # Build circuit\n num_qubits = get_num_qubits(distance)\n prog = make_surface_code(distance=distance, num_rounds=num_rounds, basis=basis)\n hugr_bytes = compile_guppy_to_hugr(prog)\n instance = build(hugr_bytes, name=f\"surface_d{distance}\")\n\n # Run\n num_logical_errors = 0\n num_raw_errors = 0\n\n for shot_results in instance.run_shots(\n simulator=Stim(),\n n_qubits=num_qubits,\n n_shots=num_shots,\n error_model=error_model,\n runtime=SimpleRuntime(),\n n_processes=1,\n ):\n # Collect all syndromes properly (multiple entries per key)\n synx_list = []\n synz_list = []\n final = None\n\n for name, values in shot_results:\n vals = list(values)\n if name == \"synx\":\n synx_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"synz\":\n synz_list.append(np.array(vals, dtype=np.uint8))\n elif name == \"final\":\n final = vals\n\n if final is None:\n continue\n\n # Raw parity check (no decoding)\n raw_parity = sum(final[q] for q in logical_qubits) % 2\n if raw_parity != 0:\n num_raw_errors += 1\n\n if decode and decoder is not None:\n final_arr = np.array(final, dtype=np.uint8)\n\n # Decode based on basis\n if basis == \"Z\":\n is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final_arr)\n else:\n is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final_arr)\n\n if is_error:\n num_logical_errors += 1\n else:\n # No decoding - use raw parity\n if raw_parity != 0:\n num_logical_errors += 1\n\n return {\n \"distance\": distance,\n \"num_shots\": num_shots,\n \"num_logical_errors\": num_logical_errors,\n \"num_raw_errors\": num_raw_errors,\n \"logical_error_rate\": num_logical_errors / num_shots,\n \"raw_error_rate\": num_raw_errors / num_shots,\n \"decoded\": decode,\n \"decoder_type\": decoder_type if decode else None,\n }" }, { "cell_type": "markdown", @@ -1094,7 +1094,7 @@ "\n", "# Create a decoder configuration\n", "patch = SurfacePatch.create(distance=3)\n", - "noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_init=0.001)\n", + "noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001)\n", "\n", "# Generate DEM using PECOS native pipeline\n", "tc = generate_tick_circuit_from_patch(patch, num_rounds=3, basis=\"Z\")\n", @@ -1110,12 +1110,12 @@ "\n", "# Build DEM\n", "builder = DemBuilder(influence_map)\n", - "builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_init)\n", + "builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep)\n", "builder.with_num_measurements(num_measurements)\n", "builder.with_measurement_order(measurement_order)\n", "builder.with_detectors_json(detectors_json)\n", "if observables_json:\n", - " builder.with_observables_json(observables_json)\n", + " builder.with_tracked_paulis_json(observables_json)\n", "\n", "pecos_dem = builder.build()\n", "\n", @@ -1535,7 +1535,7 @@ " p1=0.001, # Single-qubit gate error\n", " p2=0.01, # Two-qubit gate error\n", " p_meas=0.01, # Measurement error\n", - " p_init=0.001, # Initialization error\n", + " p_prep=0.001, # Initialization error\n", ")\n", "\n", "# Parse and generate DEM\n", @@ -1596,7 +1596,7 @@ "\n", "# Construct DEM\n", "builder = DemBuilder(influence_map)\n", - "builder.with_noise(p1, p2, p_meas, p_init)\n", + "builder.with_noise(p1, p2, p_meas, p_prep)\n", "builder.with_detectors_json(tc.get_meta(\"detectors\"))\n", "dem = builder.build()\n", "\n", @@ -1650,7 +1650,7 @@ " p1=0.001, # Single-qubit gate error rate\n", " p2=0.01, # Two-qubit gate error rate\n", " p_meas=0.01, # Measurement error rate\n", - " p_init=0.001, # Initialization error rate\n", + " p_prep=0.001, # Initialization error rate\n", ")\n", "```" ] diff --git a/examples/surface_code_selene_demo.ipynb b/examples/surface_code_selene_demo.ipynb index d9b849f24..3844f7882 100644 --- a/examples/surface_code_selene_demo.ipynb +++ b/examples/surface_code_selene_demo.ipynb @@ -84,7 +84,7 @@ "P1 = 4e-5 # Single-qubit gate error\n", "P2 = 1e-3 # Two-qubit gate error\n", "P_MEAS = 4e-4 # Measurement error\n", - "P_INIT = 4e-4 # Initialization error" + "P_PREP = 4e-4 # Initialization error" ] }, { @@ -214,7 +214,7 @@ "print()\n", "\n", "# 2. Stim circuit (for Stim simulation and DEM generation)\n", - "stim_circuit = generate_stim_from_patch(patch, NUM_ROUNDS, BASIS, p1=P1, p2=P2, p_meas=P_MEAS, p_init=P_INIT)\n", + "stim_circuit = generate_stim_from_patch(patch, NUM_ROUNDS, BASIS, p1=P1, p2=P2, p_meas=P_MEAS, p_prep=P_PREP)\n", "stim_lines = stim_circuit.strip().split(\"\\n\")\n", "print(f\"2. Stim circuit: {len(stim_lines)} lines\")\n", "print(f\" Preview: {stim_lines[0]}\")\n", @@ -668,7 +668,7 @@ "PECOS provides native DEM generation that works directly with TickCircuit,\n", "without requiring Stim as an intermediate step. The workflow is:\n", "\n", - "1. `SurfacePatch` -> `TickCircuit` (with detector/observable metadata)\n", + "1. `SurfacePatch` -> `TickCircuit` (with detector/tracked-Pauli metadata)\n", "2. `TickCircuit` -> `DagCircuit` -> `DagFaultAnalyzer` -> `DemBuilder`\n", "3. `DemBuilder` -> DEM string (Stim-compatible format)\n", "\n", @@ -709,7 +709,7 @@ " p1: float,\n", " p2: float,\n", " p_meas: float,\n", - " p_init: float,\n", + " p_prep: float,\n", ") -> \"DetectorErrorModel\":\n", " \"\"\"Generate DEM using PECOS native fault propagation.\n", "\n", @@ -731,12 +731,12 @@ "\n", " # Build DEM using Rust DemBuilder\n", " builder = DemBuilder(influence_map)\n", - " builder.with_noise(p1, p2, p_meas, p_init)\n", + " builder.with_noise(p1, p2, p_meas, p_prep)\n", " builder.with_num_measurements(num_measurements)\n", " builder.with_measurement_order(measurement_order)\n", " builder.with_detectors_json(detectors_json)\n", " if observables_json:\n", - " builder.with_observables_json(observables_json)\n", + " builder.with_tracked_paulis_json(observables_json)\n", "\n", " return builder.build()" ] @@ -773,7 +773,7 @@ "# Method 1: Via Stim (for reference)\n", "noisy_stim = generate_stim_from_patch(\n", " patch, num_rounds=NUM_ROUNDS, basis=BASIS,\n", - " p1=P1, p2=P2, p_meas=P_MEAS, p_init=P_INIT,\n", + " p1=P1, p2=P2, p_meas=P_MEAS, p_prep=P_PREP,\n", ")\n", "circuit = stim.Circuit(noisy_stim)\n", "stim_dem = circuit.detector_error_model(decompose_errors=True)\n", @@ -788,7 +788,7 @@ "# Method 2: Native PECOS\n", "pecos_dem = generate_pecos_dem(\n", " patch, num_rounds=NUM_ROUNDS, basis=BASIS,\n", - " p1=P1, p2=P2, p_meas=P_MEAS, p_init=P_INIT,\n", + " p1=P1, p2=P2, p_meas=P_MEAS, p_prep=P_PREP,\n", ")\n", "pecos_raw_str = pecos_dem.to_string()\n", "pecos_decomp_str = pecos_dem.to_string_decomposed()\n", @@ -935,7 +935,7 @@ " Stim DEM raw: 2063 lines\n", " Stim DEM decomposed: 2384 lines\n", "\n", - "Noise parameters: p1=4e-05, p2=0.001, p_meas=0.0004, p_init=0.0004\n", + "Noise parameters: p1=4e-05, p2=0.001, p_meas=0.0004, p_prep=0.0004\n", "Syndrome rounds: 3\n" ] } @@ -969,13 +969,13 @@ " # Generate Stim circuit (with noise)\n", " stim_full = generate_stim_from_patch(\n", " patch, num_rounds=NUM_ROUNDS, basis=basis,\n", - " p1=P1, p2=P2, p_meas=P_MEAS, p_init=P_INIT,\n", + " p1=P1, p2=P2, p_meas=P_MEAS, p_prep=P_PREP,\n", " )\n", "\n", " # Generate PECOS DEMs (raw and decomposed)\n", " pecos_dem_obj = generate_pecos_dem(\n", " patch, num_rounds=NUM_ROUNDS, basis=basis,\n", - " p1=P1, p2=P2, p_meas=P_MEAS, p_init=P_INIT,\n", + " p1=P1, p2=P2, p_meas=P_MEAS, p_prep=P_PREP,\n", " )\n", " pecos_dem_raw = pecos_dem_obj.to_string()\n", " pecos_dem_decomposed = pecos_dem_obj.to_string_decomposed()\n", @@ -1005,7 +1005,7 @@ " print(f\" Stim DEM decomposed: {len(stim_dem_decomposed.splitlines())} lines\")\n", " print()\n", "\n", - "print(f\"Noise parameters: p1={P1}, p2={P2}, p_meas={P_MEAS}, p_init={P_INIT}\")\n", + "print(f\"Noise parameters: p1={P1}, p2={P2}, p_meas={P_MEAS}, p_prep={P_PREP}\")\n", "print(f\"Syndrome rounds: {NUM_ROUNDS}\")" ] }, @@ -4527,13 +4527,13 @@ " p = SurfacePatch.create(distance=d)\n", "\n", " # Stim DEM (decomposed)\n", - " stim_str = generate_stim_from_patch(p, NUM_ROUNDS, BASIS, p1=P1, p2=P2, p_meas=P_MEAS, p_init=P_INIT)\n", + " stim_str = generate_stim_from_patch(p, NUM_ROUNDS, BASIS, p1=P1, p2=P2, p_meas=P_MEAS, p_prep=P_PREP)\n", " c = stim.Circuit(stim_str)\n", " stim_dem = c.detector_error_model(decompose_errors=True)\n", " stim_errors = count_dem_errors(str(stim_dem))\n", "\n", " # PECOS DEM (decomposed)\n", - " pecos_dem = generate_pecos_dem(p, NUM_ROUNDS, BASIS, p1=P1, p2=P2, p_meas=P_MEAS, p_init=P_INIT)\n", + " pecos_dem = generate_pecos_dem(p, NUM_ROUNDS, BASIS, p1=P1, p2=P2, p_meas=P_MEAS, p_prep=P_PREP)\n", " pecos_decomposed = pecos_dem.to_string_decomposed()\n", " pecos_errors = count_dem_errors(pecos_decomposed)\n", " pecos_prob = sum_dem_probability(pecos_decomposed)\n", @@ -4571,7 +4571,7 @@ "tc = generate_tick_circuit_from_patch(patch, num_rounds=NUM_ROUNDS, basis=BASIS)\n", "\n", "# One-liner DEM generation from TickCircuit (outputs decomposed format by default)\n", - "dem_str = generate_dem_from_tick_circuit(tc, p1=P1, p2=P2, p_meas=P_MEAS, p_init=P_INIT)\n", + "dem_str = generate_dem_from_tick_circuit(tc, p1=P1, p2=P2, p_meas=P_MEAS, p_prep=P_PREP)\n", "\n", "print(\"=== generate_dem_from_tick_circuit output (first 10 error lines) ===\")\n", "error_lines = [err_line for err_line in dem_str.split(\"\\n\") if err_line.startswith(\"error(\")]\n", diff --git a/examples/surface_code_slr_exploration.ipynb b/examples/surface_code_slr_exploration.ipynb index c2acf6bc6..4af7f0ec4 100644 --- a/examples/surface_code_slr_exploration.ipynb +++ b/examples/surface_code_slr_exploration.ipynb @@ -630,7 +630,7 @@ "\n", "4. **Key gaps for parity with circuit builder:**\n", " - No TickCircuit generator\n", - " - No detector/observable annotation support\n", + " - No detector/tracked-Pauli annotation support\n", " - No semantic phase metadata\n", " - No gate-level metadata (labels, stabilizer info)\n", " - No CNOT scheduling in SLR's surface library\n", diff --git a/examples/surface_code_threshold.ipynb b/examples/surface_code_threshold.ipynb index f0ae082f3..90e15a997 100644 --- a/examples/surface_code_threshold.ipynb +++ b/examples/surface_code_threshold.ipynb @@ -18,7 +18,7 @@ "\n", "**Setup:**\n", "- Distances 9, 11, 13, 15 (avoiding finite-size effects)\n", - "- Uniform depolarizing noise (p1 = p2 = p_meas = p_init = p)\n", + "- Uniform depolarizing noise (p1 = p2 = p_meas = p_prep = p)\n", "- 2*d syndrome rounds per distance\n", "- Selene simulation with Stim backend\n", "\n", @@ -164,12 +164,12 @@ " measurement_order = _extract_measurement_order(tc)\n", "\n", " builder = DemBuilder(influence_map)\n", - " builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_init)\n", + " builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep)\n", " builder.with_num_measurements(num_measurements)\n", " builder.with_measurement_order(measurement_order)\n", " builder.with_detectors_json(detectors_json)\n", " if observables_json:\n", - " builder.with_observables_json(observables_json)\n", + " builder.with_tracked_paulis_json(observables_json)\n", "\n", " dem = builder.build()\n", " return dem.to_string(), dem.to_string_decomposed()\n", @@ -438,7 +438,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_init=p)\n", + " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " t0 = time.time()\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", @@ -487,7 +487,7 @@ "\n", " for p in ERROR_RATES:\n", " error_model = DepolarizingErrorModel(p_1q=p, p_2q=p, p_meas=p, p_init=p)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_init=p)\n", + " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", "\n", " t0 = time.time()\n", " shots = run_shots(instance, nq, NUM_SHOTS, error_model)\n", @@ -646,7 +646,7 @@ "by comparing 5 decoder/DEM variants, all using PyMatching MWPM.\n", "\n", "**Setup:**\n", - "- Uniform depolarizing noise: p1 = p2 = p_meas = p_init = p\n", + "- Uniform depolarizing noise: p1 = p2 = p_meas = p_prep = p\n", "- 2*d syndrome rounds per distance\n", "- Distances 9-15 to minimize finite-size effects\n", "\n", diff --git a/examples/surface_code_thresholds.ipynb b/examples/surface_code_thresholds.ipynb index cdde5afb8..2f5050c2e 100644 --- a/examples/surface_code_thresholds.ipynb +++ b/examples/surface_code_thresholds.ipynb @@ -339,7 +339,7 @@ "def generate_code_capacity_dem(patch: SurfacePatch, num_rounds: int, p: float) -> str:\n", " \"\"\"Generate a code-capacity DEM (data errors only, perfect measurements).\"\"\"\n", " # Use phenomenological DEM with p_meas=0\n", - " noise = NoiseModel(p1=0, p2=p, p_meas=0, p_init=0)\n", + " noise = NoiseModel(p1=0, p2=p, p_meas=0, p_prep=0)\n", " return generate_surface_code_dem(patch, num_rounds=1, noise=noise, stab_type=\"Z\")\n", "\n", "# Test DEM generation\n", @@ -424,7 +424,7 @@ "source": [ "def generate_phenomenological_dem(patch: SurfacePatch, num_rounds: int, p: float) -> str:\n", " \"\"\"Generate a phenomenological DEM (data + measurement errors).\"\"\"\n", - " noise = NoiseModel(p1=0, p2=p, p_meas=p, p_init=0)\n", + " noise = NoiseModel(p1=0, p2=p, p_meas=p, p_prep=0)\n", " return generate_surface_code_dem(patch, num_rounds=num_rounds, noise=noise, stab_type=\"Z\")\n", "\n", "# Test DEM generation\n", @@ -510,7 +510,7 @@ "def generate_circuit_level_dem(patch: SurfacePatch, num_rounds: int, p: float) -> str:\n", " \"\"\"Generate a circuit-level DEM using PECOS native fault propagation.\"\"\"\n", " # Use same error rate for all noise sources (standard depolarizing)\n", - " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_init=p)\n", + " noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p)\n", " return generate_circuit_level_dem_from_builder(patch, num_rounds=num_rounds, noise=noise, basis=\"Z\")\n", "\n", "# Test DEM generation\n", diff --git a/exp/pecos-eeg/Cargo.toml b/exp/pecos-eeg/Cargo.toml new file mode 100644 index 000000000..ceef7c350 --- /dev/null +++ b/exp/pecos-eeg/Cargo.toml @@ -0,0 +1,29 @@ +[package] +name = "pecos-eeg" +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 = "EEG-based coherent noise analysis and DEM generation" +publish = false + +[lib] +crate-type = ["rlib"] + +[dependencies] +pecos-core.workspace = true +pecos-qec.workspace = true +pecos-quantum.workspace = true +pecos-random.workspace = true +pecos-simulators.workspace = true +serde_json.workspace = true +rayon.workspace = true +smallvec.workspace = true +thiserror.workspace = true + +[lints] +workspace = true diff --git a/exp/pecos-eeg/examples/profile_heisenberg.rs b/exp/pecos-eeg/examples/profile_heisenberg.rs new file mode 100644 index 000000000..2f5d95bcd --- /dev/null +++ b/exp/pecos-eeg/examples/profile_heisenberg.rs @@ -0,0 +1,101 @@ +//! Profile the Heisenberg DEM build. +//! Usage: cargo run -p pecos-eeg --example `profile_heisenberg` --profile profiling +//! Perf: perf record -g -F 4999 -- `target/profiling/examples/profile_heisenberg` + +use pecos_core::gate_type::GateType; +use pecos_core::pauli::pauli_bitmask::BitmaskStorage; +use pecos_core::{Gate, GateAngles, GateParams, QubitId}; +use pecos_eeg::Bm; +use std::time::Instant; + +fn gate(gt: GateType, qubits: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qubits.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } +} + +/// Build a single weight-4 X-check plaquette with 4 data + 1 ancilla, N rounds. +/// This is the hotspot structure in surface codes. +fn build_weight4_circuit(num_rounds: usize) -> Vec { + // Data: 0,1,2,3. Ancilla: 4. + let mut gates = Vec::new(); + for q in 0..5 { + gates.push(gate(GateType::PZ, &[q])); + } + for round in 0..num_rounds { + gates.push(gate(GateType::H, &[4])); + gates.push(gate(GateType::CX, &[4, 0])); + gates.push(gate(GateType::CX, &[4, 1])); + gates.push(gate(GateType::CX, &[4, 2])); + gates.push(gate(GateType::CX, &[4, 3])); + gates.push(gate(GateType::H, &[4])); + gates.push(gate(GateType::MZ, &[4])); + if round < num_rounds - 1 { + gates.push(gate(GateType::PZ, &[4])); + } + } + for q in 0..4 { + gates.push(gate(GateType::MZ, &[q])); + } + gates +} + +fn main() { + let num_rounds = 8; + let gates = build_weight4_circuit(num_rounds); + let noise = pecos_eeg::noise::UniformNoise::coherent_only(0.05); + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let na = 1; // one ancilla + + let mut detectors = Vec::new(); + for round in 0..(num_rounds - 1) { + let m1 = round * na; + let m2 = (round + 1) * na; + let mut det = Bm::default(); + det.z_bits.set_bit(expanded.measurement_qubit[m1]); + det.z_bits.set_bit(expanded.measurement_qubit[m2]); + detectors.push(det); + } + + let init_gates: Vec = (0..5) + .map(|q| pecos_eeg::expand::make_gate(GateType::PZ, &[q])) + .collect(); + let stab = + pecos_eeg::stabilizer::StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + + eprintln!( + "Weight-4 X-check, {num_rounds} rounds, {} expanded qubits, {} detectors", + expanded.num_qubits, + detectors.len() + ); + + // Run 20 iterations to get enough samples for perf + let iters = 20; + let t = Instant::now(); + for _ in 0..iters { + for det in &detectors { + let _p = pecos_eeg::heisenberg::heisenberg_detection_probability( + &expanded.gates, + det, + &noise, + &stab, + 0.0, + ); + } + } + let total = t.elapsed(); + let calls = u32::try_from(detectors.len() * iters).expect("profile call count fits in u32"); + let per_det = total.as_secs_f64() * 1000.0 / f64::from(calls); + eprintln!( + "{iters} iterations x {} dets = {} calls in {:.2}s ({:.2}ms/det)", + detectors.len(), + detectors.len() * iters, + total.as_secs_f64(), + per_det + ); +} diff --git a/exp/pecos-eeg/src/builder.rs b/exp/pecos-eeg/src/builder.rs new file mode 100644 index 000000000..4eaa9cad6 --- /dev/null +++ b/exp/pecos-eeg/src/builder.rs @@ -0,0 +1,407 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! EEG DEM builder: TickCircuit + noise → DEM events. + +use crate::Bm; +use crate::circuit::{self, NoiseModel}; +use crate::dem_mapping::{self, DemEntry, Detector, Observable}; +use crate::expand; +use crate::stabilizer::StabilizerGroup; +use pecos_core::pauli::pauli_bitmask::BitmaskStorage; +use pecos_quantum::{AnnotationKind, TickCircuit}; + +pub struct EegDemBuilder<'a> { + tc: &'a TickCircuit, + noise: NoiseModel, + config: dem_mapping::EegConfig, +} + +impl<'a> EegDemBuilder<'a> { + #[must_use] + pub fn from_tick_circuit(tc: &'a TickCircuit) -> Self { + Self { + tc, + noise: NoiseModel::coherent_only(0.0), + config: dem_mapping::EegConfig::default(), + } + } + + #[must_use] + pub fn noise(mut self, noise: NoiseModel) -> Self { + self.noise = noise; + self + } + + /// Set the full EEG configuration. + #[must_use] + pub fn config(mut self, config: dem_mapping::EegConfig) -> Self { + self.config = config; + self + } + + /// Use the exact sin^2(h) formula instead of leading-order h^2. + #[must_use] + pub fn exact_h_formula(mut self) -> Self { + self.config.h_formula = dem_mapping::HFormula::SinSquared; + self + } + + /// Use second-order BCH (includes [H,H] commutator corrections). + #[must_use] + pub fn bch_order_2(mut self) -> Self { + self.config.bch_order = dem_mapping::BchOrder::Second; + self + } + + #[must_use] + pub fn build(&self) -> Vec { + let gates: Vec = self + .tc + .iter_gate_batches() + .map(|batch| batch.as_gate().clone()) + .collect(); + let expanded = expand::expand_circuit(&gates); + let result = circuit::analyze_expanded(&expanded.gates, &self.noise); + let (detectors, observables) = build_detectors(self.tc, &expanded); + + // Compute stabilizer group from the EXPANDED circuit (pre-readout). + // This includes auxiliary qubits, so beta function checks happen + // directly in the expanded frame without lossy frame mapping. + // Exclude the final deferred MZ(aux) gates at the end. + let expanded_pre_readout = exclude_final_mz(&expanded.gates); + let stab_group = StabilizerGroup::from_circuit(&expanded_pre_readout, expanded.num_qubits); + + dem_mapping::build_dem_configured( + &result.generators, + &detectors, + &observables, + Some(&stab_group), + &self.config, + ) + } + + #[must_use] + pub fn build_dem_string(&self) -> String { + dem_mapping::format_dem(&self.build()) + } + + #[must_use] + pub fn summary(&self) -> EegSummary { + let gates: Vec = self + .tc + .iter_gate_batches() + .map(|batch| batch.as_gate().clone()) + .collect(); + let expanded = expand::expand_circuit(&gates); + let result = circuit::analyze_expanded(&expanded.gates, &self.noise); + let (detectors, observables) = build_detectors(self.tc, &expanded); + + let expanded_pre = exclude_final_mz(&expanded.gates); + let stab_group = StabilizerGroup::from_circuit(&expanded_pre, expanded.num_qubits); + let entries = dem_mapping::build_dem_configured( + &result.generators, + &detectors, + &observables, + Some(&stab_group), + &self.config, + ); + + let h_count = result + .generators + .iter() + .filter(|g| g.eeg_type == crate::eeg::EegType::H) + .count(); + let s_count = result + .generators + .iter() + .filter(|g| g.eeg_type == crate::eeg::EegType::S) + .count(); + + EegSummary { + num_original_gates: gates.len(), + num_expanded_gates: expanded.gates.len(), + num_expanded_qubits: expanded.num_qubits, + num_h_generators: h_count, + num_s_generators: s_count, + num_detectors: detectors.len(), + num_observables: observables.len(), + num_dem_events: entries.len(), + generator_fidelity: result.generator_fidelity(), + } + } +} + +#[derive(Clone, Debug)] +pub struct EegSummary { + pub num_original_gates: usize, + pub num_expanded_gates: usize, + pub num_expanded_qubits: usize, + pub num_h_generators: usize, + pub num_s_generators: usize, + pub num_detectors: usize, + pub num_observables: usize, + pub num_dem_events: usize, + /// Generator fidelity: ε_gen = Σ h_P² + Σ |s_P|. DEM error scales as ε_gen^{1.5}. + pub generator_fidelity: f64, +} + +/// Strip all trailing MZ gates from the expanded circuit. +/// +/// The expanded circuit ends with deferred MZ(aux) gates. Stripping them +/// gives the pre-readout expanded state for stabilizer group computation. +fn exclude_final_mz(gates: &[pecos_core::Gate]) -> Vec { + let last_non_mz = gates + .iter() + .rposition(|g| g.gate_type != pecos_core::gate_type::GateType::MZ); + match last_non_mz { + Some(idx) => gates[..=idx].to_vec(), + None => Vec::new(), + } +} + +/// Build detectors for the expanded circuit from TickCircuit annotations. +/// +/// Each detector is defined by measurement records (negative indices from +/// the end of the measurement sequence). In the expanded circuit, each +/// measurement record k maps to a Z-measurement on auxiliary qubit +/// `expanded.measurement_qubit[k]`. +/// +/// The detector stabilizer in the expanded circuit is: +/// Z_{aux_r1} * Z_{aux_r2} * ... +/// where aux_ri = expanded.measurement_qubit[abs_index(ri)] +fn build_detectors( + tc: &TickCircuit, + expanded: &expand::ExpandedCircuit, +) -> (Vec, Vec) { + let mut detectors = Vec::new(); + let mut observables = Vec::new(); + let num_meas = expanded.measurement_qubit.len(); + + for annotation in tc.annotations() { + match &annotation.kind { + AnnotationKind::Detector { + measurement_nodes, .. + } => { + // measurement_nodes are gate indices in the ORIGINAL circuit. + // We need to map these to measurement record indices, then + // to auxiliary qubits in the expanded circuit. + // + // The gate indices correspond to MZ gates. Each MZ gate can + // measure multiple qubits. We need to find which measurement + // record each gate index maps to. + // + // Strategy: the k-th measurement record corresponds to the + // k-th qubit measured across all MZ gates in order. Each + // measurement_node is a gate index — we find which measurement + // records that gate produced. + let bitmask = + measurement_nodes_to_aux_bitmask(measurement_nodes, tc, expanded, num_meas); + detectors.push(Detector { + id: detectors.len(), + stabilizer: bitmask, + }); + } + AnnotationKind::Observable { + measurement_nodes, .. + } => { + let bitmask = + measurement_nodes_to_aux_bitmask(measurement_nodes, tc, expanded, num_meas); + observables.push(Observable { + id: observables.len(), + pauli: bitmask, + }); + } + AnnotationKind::TrackedPauli => {} + } + } + + (detectors, observables) +} + +/// Map measurement record indices to a Z bitmask on auxiliary qubits. +/// +/// Each measurement_node is a measurement record index (counting all +/// MZ qubits in circuit order). Maps directly to an auxiliary qubit +/// in the expanded circuit via `expanded.measurement_qubit[record]`. +fn measurement_nodes_to_aux_bitmask( + measurement_nodes: &[usize], + _tc: &TickCircuit, + expanded: &expand::ExpandedCircuit, + num_meas: usize, +) -> Bm { + let mut bitmask = Bm::default(); + + for &record_idx in measurement_nodes { + if record_idx < num_meas { + let aux_qubit = expanded.measurement_qubit[record_idx]; + bitmask.z_bits.xor_bit(aux_qubit); + } + } + + bitmask +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_empty_no_noise() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0]); + tc.tick().mz(&[0]); + let entries = EegDemBuilder::from_tick_circuit(&tc) + .noise(NoiseModel::coherent_only(0.0)) + .build(); + assert!(entries.is_empty()); + } + + #[test] + fn test_summary_coherent() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + tc.tick().mz(&[0, 1]); + let summary = EegDemBuilder::from_tick_circuit(&tc) + .noise(NoiseModel::coherent_only(0.1)) + .summary(); + assert!(summary.num_h_generators > 0); + assert_eq!(summary.num_s_generators, 0); + } + + #[test] + fn test_builder_matches_manual_pipeline() { + // Same circuit through builder and manual pipeline should give same DEM + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + tc.tick().h(&[0]); + tc.tick().cx(&[(0, 1)]); + tc.tick().mz(&[0, 1]); + + let noise = NoiseModel::coherent_only(0.05); + + // Builder path + let builder_entries = EegDemBuilder::from_tick_circuit(&tc) + .noise(noise.clone()) + .build(); + + // Manual path + let gates: Vec = tc + .iter_gate_batches() + .map(|batch| batch.as_gate().clone()) + .collect(); + let expanded = expand::expand_circuit(&gates); + let result = circuit::analyze_expanded(&expanded.gates, &noise); + + let expanded_pre = exclude_final_mz(&expanded.gates); + let stab_group = StabilizerGroup::from_circuit(&expanded_pre, expanded.num_qubits); + + let (detectors, observables) = build_detectors(&tc, &expanded); + let manual_entries = dem_mapping::build_dem_with_stabilizers( + &result.generators, + &detectors, + &observables, + Some(&stab_group), + ); + + // Same number of entries + assert_eq!( + builder_entries.len(), + manual_entries.len(), + "Builder and manual should produce same number of DEM entries" + ); + + // Same probabilities (order may differ, so sort) + let mut bp: Vec = builder_entries.iter().map(|e| e.probability).collect(); + let mut mp: Vec = manual_entries.iter().map(|e| e.probability).collect(); + bp.sort_by(|a, b| a.partial_cmp(b).unwrap()); + mp.sort_by(|a, b| a.partial_cmp(b).unwrap()); + for (b, m) in bp.iter().zip(mp.iter()) { + assert!( + (b - m).abs() < 1e-15, + "Probability mismatch: builder={b}, manual={m}" + ); + } + } + + #[test] + fn test_no_annotations_empty_dem() { + // Without detector/observable annotations, builder should produce empty DEM + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1]); + tc.tick().cx(&[(0, 1)]); + tc.tick().mz(&[0, 1]); + + let entries = EegDemBuilder::from_tick_circuit(&tc) + .noise(NoiseModel::depolarizing(0.01)) + .build(); + + assert!( + entries.is_empty(), + "No annotations → no detectors → no DEM entries" + ); + } + + #[test] + fn test_with_detector_annotations() { + // Build a circuit with detector annotations + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1, 2]); + + // Round 1: syndrome extraction + tc.tick().cx(&[(0, 2)]); + tc.tick().cx(&[(1, 2)]); + let m1 = tc.tick().mz(&[2]); + + // Round 2: syndrome extraction + tc.tick().pz(&[2]); + tc.tick().cx(&[(0, 2)]); + tc.tick().cx(&[(1, 2)]); + let m2 = tc.tick().mz(&[2]); + + // Detector: compare m1 and m2 + tc.detector(&[m1[0], m2[0]]); + + // Final readout + tc.tick().mz(&[0, 1]); + + let entries = EegDemBuilder::from_tick_circuit(&tc) + .noise(NoiseModel::depolarizing(0.01)) + .build(); + + assert!( + !entries.is_empty(), + "Circuit with detector annotation should produce DEM entries" + ); + for e in &entries { + assert!(e.probability > 0.0); + assert!(e.probability < 0.5); + } + } + + #[test] + fn test_summary_counts() { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1, 2]); + tc.tick().cx(&[(0, 1)]); + tc.tick().cx(&[(1, 2)]); + tc.tick().mz(&[0, 1, 2]); + + let summary = EegDemBuilder::from_tick_circuit(&tc) + .noise(NoiseModel::depolarizing(0.01).with_idle_rz(0.05)) + .summary(); + + assert!( + summary.num_h_generators > 0, + "Should have H generators from idle RZ" + ); + assert!( + summary.num_s_generators > 0, + "Should have S generators from depolarizing" + ); + assert_eq!(summary.num_expanded_qubits, 6, "3 original + 3 aux"); + } +} diff --git a/exp/pecos-eeg/src/circuit.rs b/exp/pecos-eeg/src/circuit.rs new file mode 100644 index 000000000..fbc4fda73 --- /dev/null +++ b/exp/pecos-eeg/src/circuit.rs @@ -0,0 +1,717 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! EEG circuit analysis on the expanded (measurement-deferred) circuit. +//! +//! After expansion, the circuit is purely Clifford. Error generators are +//! propagated straight to the end via Clifford conjugation (no measurement +//! absorption). At the end, each Pauli P flips measurement k iff P has +//! X on measurement_qubit[k]. + +use crate::Bm; +use crate::eeg::EegType; +use pecos_core::Gate; +use pecos_core::gate_type::GateType; +use pecos_core::pauli::pauli_bitmask::{ + BitmaskStorage, Conjugated, conjugate_cx, conjugate_cy, conjugate_cz, conjugate_h, + conjugate_swap, conjugate_sx, conjugate_sxdg, conjugate_sy, conjugate_sydg, conjugate_sz, + conjugate_szdg, conjugate_x, conjugate_y, conjugate_z, +}; + +/// Noise model parameters. +#[derive(Clone, Debug)] +pub struct NoiseModel { + /// Coherent RZ angle (radians) on both qubits after each 2-qubit gate. + pub idle_rz: f64, + /// Single-qubit depolarizing probability. + pub p1: f64, + /// Two-qubit depolarizing probability. + pub p2: f64, + /// Measurement bit-flip probability. + pub p_meas: f64, + /// Preparation error probability. + pub p_prep: f64, +} + +impl NoiseModel { + #[must_use] + pub fn coherent_only(idle_rz: f64) -> Self { + Self { + idle_rz, + p1: 0.0, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + } + } + + #[must_use] + pub fn depolarizing(p: f64) -> Self { + Self { + idle_rz: 0.0, + p1: p, + p2: p, + p_meas: p, + p_prep: p, + } + } + + #[must_use] + pub fn with_idle_rz(mut self, angle: f64) -> Self { + self.idle_rz = angle; + self + } +} + +/// Identifies the physical noise source that produced a generator. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +pub struct NoiseSource { + /// Index of the gate in the expanded circuit that the noise follows. + pub gate_index: usize, + /// Qubit the noise acts on (for per-qubit noise like idle RZ). + pub qubit: usize, +} + +/// A propagated EEG generator at the end of the expanded circuit. +#[derive(Clone, Debug)] +pub struct PropagatedEeg { + /// EEG type (H, S, C, or A). + pub eeg_type: EegType, + /// Primary Pauli label at end of circuit. + pub label: Bm, + /// Second Pauli label for C and A types (None for H and S). + pub label2: Option, + /// Coefficient (rate). For H: includes sign from conjugation. For S: always negative. + pub coeff: f64, + /// Physical noise source that produced this generator (for sensitivity analysis). + pub source: Option, +} + +/// Result of EEG analysis on the expanded circuit. +#[derive(Clone, Debug)] +pub struct EegAnalysisResult { + /// All propagated generators at end of circuit. + pub generators: Vec, + /// Number of measurement records. + pub num_measurements: usize, +} + +impl EegAnalysisResult { + /// Generator fidelity ε_gen = Σ h_P² + Σ |s_P| (Hines Eq. 2/Eq. 6). + /// + /// Measures the total "size" of the error. The DEM prediction error + /// (TVD) scales as ε_gen^{1.5}. + #[must_use] + pub fn generator_fidelity(&self) -> f64 { + let mut eps = 0.0; + for g in &self.generators { + match g.eeg_type { + EegType::H => eps += g.coeff * g.coeff, + EegType::S => eps += g.coeff.abs(), + _ => {} + } + } + eps + } +} + +/// Analyze the expanded circuit with a flexible noise specification. +/// +/// For each gate, calls `noise.noise_after_gate()` to get generators, +/// then propagates them to the end via Clifford conjugation. +/// +/// Expansion gates (QAlloc, expansion CX, expansion PZ) are skipped +/// for noise injection. +pub fn analyze_with_noise( + gates: &[Gate], + noise: &dyn crate::noise::NoiseSpec, +) -> EegAnalysisResult { + let mut generators = Vec::new(); + let mut num_measurements = 0; + + // Build sets of expansion gate indices + let mut expansion_cx_indices = std::collections::HashSet::new(); + let mut expansion_pz_indices = std::collections::HashSet::new(); + for i in 1..gates.len() { + if gates[i].gate_type == GateType::CX && gates[i - 1].gate_type == GateType::QAlloc { + let alloc_q = gates[i - 1].qubits[0].index(); + if gates[i].qubits.len() >= 2 && gates[i].qubits[1].index() == alloc_q { + expansion_cx_indices.insert(i); + if i + 1 < gates.len() && gates[i + 1].gate_type == GateType::PZ { + let cx_control = gates[i].qubits[0].index(); + if gates[i + 1].qubits.len() == 1 + && gates[i + 1].qubits[0].index() == cx_control + { + expansion_pz_indices.insert(i + 1); + } + } + } + } + } + + for (i, gate) in gates.iter().enumerate() { + let remaining = &gates[i + 1..]; + let qubits: Vec = gate.qubits.iter().map(pecos_core::QubitId::index).collect(); + + // Skip expansion gates (virtual, not physical) + let is_expansion = expansion_cx_indices.contains(&i) + || expansion_pz_indices.contains(&i) + || gate.gate_type == GateType::QAlloc; + + if !is_expansion { + // Get noise generators from the noise specification + let injections = noise.noise_after_gate(i, gate.gate_type, &qubits); + + for inj in injections { + match inj.eeg_type { + EegType::H => { + let (pl, coeff) = propagate_h(inj.label, inj.rate, remaining); + generators.push(PropagatedEeg { + eeg_type: EegType::H, + label: pl, + label2: None, + coeff, + source: Some(NoiseSource { + gate_index: i, + qubit: qubits.first().copied().unwrap_or(0), + }), + }); + } + EegType::S => { + let (pl, _) = propagate_s(inj.label, remaining); + generators.push(PropagatedEeg { + eeg_type: EegType::S, + label: pl, + label2: None, + coeff: inj.rate, + source: None, + }); + } + EegType::C | EegType::A => { + if let Some(label2) = inj.label2 { + let (l1, l2, coeff) = + propagate_ca(inj.label, label2, inj.rate, remaining); + generators.push(PropagatedEeg { + eeg_type: inj.eeg_type, + label: l1, + label2: Some(l2), + coeff, + source: None, + }); + } + } + } + } + } + + // Handle explicit RZ gates (from the circuit, not noise model) + if gate.gate_type == GateType::RZ + && let Some(&angle) = gate.angles.first() + { + for &q in &qubits { + let label = Bm::z(q); + let (pl, coeff) = propagate_h(label, angle.to_radians() / 2.0, remaining); + generators.push(PropagatedEeg { + eeg_type: EegType::H, + label: pl, + label2: None, + coeff, + source: Some(NoiseSource { + gate_index: i, + qubit: q, + }), + }); + } + } + + // Count measurements + if gate.gate_type == GateType::MZ { + num_measurements += qubits.len(); + } + } + + EegAnalysisResult { + generators, + num_measurements, + } +} + +/// Analyze the expanded circuit with the legacy NoiseModel. +/// +/// Delegates to `analyze_with_noise` using a `UniformNoise` specification. +#[must_use] +pub fn analyze_expanded(gates: &[Gate], noise: &NoiseModel) -> EegAnalysisResult { + let uniform = crate::noise::UniformNoise { + idle_rz: noise.idle_rz, + p1: noise.p1, + p2: noise.p2, + p_meas: noise.p_meas, + p_prep: noise.p_prep, + }; + analyze_with_noise(gates, &uniform) +} + +/// Propagate H_P forward: sign changes under Clifford conjugation. +/// PZ/QAlloc clears all Pauli components on the reset qubit. +fn propagate_h(mut label: Bm, mut coeff: f64, remaining: &[Gate]) -> (Bm, f64) { + for gate in remaining { + match gate.gate_type { + GateType::PZ | GateType::QAlloc => { + // Reset removes any error on this qubit + for q in &gate.qubits { + label.x_bits.clear_bit(q.index()); + label.z_bits.clear_bit(q.index()); + } + } + _ => { + if let Some(r) = conjugate_by_gate(&label, gate) { + label = r.label; + if r.sign_negative { + coeff = -coeff; + } + } + } + } + } + (label, coeff) +} + +/// Propagate C_{P,Q} or A_{P,Q} forward: both labels conjugate, signs multiply. +/// gamma(C_{P,Q}, U) = s_{U,P} * s_{U,Q}. Same for A. +/// PZ/QAlloc clears components on both labels. +fn propagate_ca( + mut label1: Bm, + mut label2: Bm, + mut coeff: f64, + remaining: &[Gate], +) -> (Bm, Bm, f64) { + for gate in remaining { + match gate.gate_type { + GateType::PZ | GateType::QAlloc => { + for q in &gate.qubits { + label1.x_bits.clear_bit(q.index()); + label1.z_bits.clear_bit(q.index()); + label2.x_bits.clear_bit(q.index()); + label2.z_bits.clear_bit(q.index()); + } + } + _ => { + let mut sign = false; + if let Some(r) = conjugate_by_gate(&label1, gate) { + label1 = r.label; + if r.sign_negative { + sign = !sign; + } + } + if let Some(r) = conjugate_by_gate(&label2, gate) { + label2 = r.label; + if r.sign_negative { + sign = !sign; + } + } + if sign { + coeff = -coeff; + } + } + } + } + (label1, label2, coeff) +} + +/// Propagate S_P forward: no sign change (gamma(S_P, U) = 1 always). +/// PZ/QAlloc clears all Pauli components on the reset qubit. +fn propagate_s(mut label: Bm, remaining: &[Gate]) -> (Bm, f64) { + for gate in remaining { + match gate.gate_type { + GateType::PZ | GateType::QAlloc => { + for q in &gate.qubits { + label.x_bits.clear_bit(q.index()); + label.z_bits.clear_bit(q.index()); + } + } + _ => { + if let Some(r) = conjugate_by_gate(&label, gate) { + label = r.label; + } + } + } + } + (label, 0.0) +} + +fn conjugate_by_gate(label: &Bm, gate: &Gate) -> Option>> { + if gate.qubits.is_empty() { + return None; + } + let q0 = || gate.qubits[0].index(); + let q1 = || gate.qubits[1].index(); + match gate.gate_type { + GateType::H => Some(conjugate_h(label, q0())), + GateType::SZ => Some(conjugate_sz(label, q0())), + GateType::SZdg => Some(conjugate_szdg(label, q0())), + GateType::SX => Some(conjugate_sx(label, q0())), + GateType::SXdg => Some(conjugate_sxdg(label, q0())), + GateType::SY => Some(conjugate_sy(label, q0())), + GateType::SYdg => Some(conjugate_sydg(label, q0())), + GateType::X => Some(conjugate_x(label, q0())), + GateType::Y => Some(conjugate_y(label, q0())), + GateType::Z => Some(conjugate_z(label, q0())), + GateType::CX => Some(conjugate_cx(label, q0(), q1())), + GateType::CY => Some(conjugate_cy(label, q0(), q1())), + GateType::CZ => Some(conjugate_cz(label, q0(), q1())), + GateType::SWAP => Some(conjugate_swap(label, q0(), q1())), + _ => None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pecos_core::{GateAngles, GateParams, QubitId}; + + fn gate(gt: GateType, qubits: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qubits.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } + } + + #[test] + fn test_rz_rate_is_half_theta() { + // Idle RZ(0.1) after CX: rate should be 0.05 (theta/2) + // because RZ(theta) = exp(-i*theta*Z/2) → H_Z with rate theta/2 + let gates = vec![gate(GateType::CX, &[0, 1])]; + let noise = NoiseModel::coherent_only(0.1); + let result = analyze_expanded(&gates, &noise); + + let h_gens: Vec<_> = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::H) + .collect(); + assert_eq!(h_gens.len(), 2); + for g in &h_gens { + assert!( + (g.coeff.abs() - 0.05).abs() < 1e-10, + "Rate should be 0.05 (theta/2), got {}", + g.coeff + ); + } + } + + #[test] + fn test_h_propagation_through_hadamard() { + // H_Z after H gate: Z → X, sign positive + let gates = vec![gate(GateType::CX, &[0, 1]), gate(GateType::H, &[0])]; + let noise = NoiseModel::coherent_only(0.1); + let result = analyze_expanded(&gates, &noise); + + let q0_gen = result + .generators + .iter() + .find(|g| g.eeg_type == EegType::H && g.label.has_x(0)) + .expect("Should have H_X on qubit 0"); + assert!( + (q0_gen.coeff - 0.05).abs() < 1e-10, + "H: Z→X, rate=theta/2=0.05" + ); + } + + #[test] + fn test_sx_propagation() { + // SX on qubit 1 after CX: Z1 → -Y1 (sign flip) + let gates = vec![gate(GateType::CX, &[0, 1]), gate(GateType::SX, &[1])]; + let noise = NoiseModel::coherent_only(0.1); + let result = analyze_expanded(&gates, &noise); + + // H_Z(1) propagated through SX(1): Z→-Y, coeff flips sign + let q1_gen = result + .generators + .iter() + .find(|g| g.eeg_type == EegType::H && g.label.has_x(1) && g.label.has_z(1)) + .expect("Should have H_Y on qubit 1 after SX"); + assert!( + (q1_gen.coeff + 0.05).abs() < 1e-10, + "SX: Z→-Y, sign flips: expected -0.05, got {}", + q1_gen.coeff + ); + + // H_Z(0) should be unaffected by SX on qubit 1 + let q0_gen = result + .generators + .iter() + .find(|g| g.eeg_type == EegType::H && g.label == Bm::z(0)) + .expect("Should still have H_Z on qubit 0"); + assert!((q0_gen.coeff - 0.05).abs() < 1e-10); + } + + #[test] + fn test_cy_propagation() { + // CY after CX: Z on target propagates like CX (Z_t → Z_c Z_t) + let gates = vec![gate(GateType::CX, &[0, 1]), gate(GateType::CY, &[0, 1])]; + let noise = NoiseModel::coherent_only(0.1); + let result = analyze_expanded(&gates, &noise); + + // H_Z(1) from CX, propagated through CY: Z_t → Z_c Z_t + let zz_gen = result + .generators + .iter() + .find(|g| g.eeg_type == EegType::H && g.label == Bm::z(0).multiply(&Bm::z(1))) + .expect("Should have Z0Z1 after CY propagation of Z1"); + assert!((zz_gen.coeff.abs() - 0.05).abs() < 1e-10); + } + + #[test] + fn test_sy_propagation() { + // SY: X→-Z, Z→X. So H_Z through SY gives H_X with no sign flip + let gates = vec![gate(GateType::CX, &[0, 1]), gate(GateType::SY, &[1])]; + let noise = NoiseModel::coherent_only(0.1); + let result = analyze_expanded(&gates, &noise); + + // H_Z(1) through SY(1): Z→X, no sign flip + let q1_gen = result + .generators + .iter() + .find(|g| g.eeg_type == EegType::H && g.label == Bm::x(1)) + .expect("Should have H_X on qubit 1 after SY"); + assert!( + (q1_gen.coeff - 0.05).abs() < 1e-10, + "SY: Z→X, no sign: expected 0.05, got {}", + q1_gen.coeff + ); + } + + #[test] + fn test_pz_clears_propagated_errors() { + // Error injected before PZ should be cleared + let gates = vec![ + gate(GateType::CX, &[0, 1]), + gate(GateType::PZ, &[1]), // Reset qubit 1 + ]; + let noise = NoiseModel::coherent_only(0.1); + let result = analyze_expanded(&gates, &noise); + + // H_Z(1) should be cleared by PZ(1) + let q1_gens: Vec<_> = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::H && (g.label.has_x(1) || g.label.has_z(1))) + .collect(); + assert!( + q1_gens.is_empty(), + "PZ should clear all error components on qubit 1" + ); + + // H_Z(0) should survive (PZ on qubit 1 doesn't touch qubit 0) + let q0_gen = result + .generators + .iter() + .find(|g| g.eeg_type == EegType::H && g.label == Bm::z(0)); + assert!(q0_gen.is_some(), "H_Z(0) should survive PZ(1)"); + } + + #[test] + fn test_no_noise_no_generators() { + let gates = vec![gate(GateType::CX, &[0, 1]), gate(GateType::H, &[0])]; + let noise = NoiseModel::coherent_only(0.0); + let result = analyze_expanded(&gates, &noise); + assert!(result.generators.is_empty()); + } + + #[test] + fn test_depol_1q_injects_three_paulis() { + // Single-qubit depolarizing on H gate produces S_X, S_Y, S_Z + let gates = vec![gate(GateType::H, &[0])]; + let noise = NoiseModel { + idle_rz: 0.0, + p1: 0.03, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }; + let result = analyze_expanded(&gates, &noise); + + let s_gens: Vec<_> = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::S) + .collect(); + assert_eq!( + s_gens.len(), + 3, + "1q depolarizing should inject 3 S generators" + ); + for g in &s_gens { + assert!( + (g.coeff + 0.01).abs() < 1e-10, + "Rate should be -p/3 = -0.01" + ); + } + } + + #[test] + fn test_depol_2q_injects_fifteen_paulis() { + // Two-qubit depolarizing on CX: 15 S generators (3 single + 3 single + 9 tensor) + let gates = vec![gate(GateType::CX, &[0, 1])]; + let noise = NoiseModel { + idle_rz: 0.0, + p1: 0.0, + p2: 0.15, + p_meas: 0.0, + p_prep: 0.0, + }; + let result = analyze_expanded(&gates, &noise); + + let s_gens: Vec<_> = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::S) + .collect(); + assert_eq!( + s_gens.len(), + 15, + "2q depolarizing should inject 15 S generators" + ); + for g in &s_gens { + assert!( + (g.coeff + 0.01).abs() < 1e-10, + "Rate should be -p/15 = -0.01" + ); + } + } + + #[test] + fn test_meas_noise_injects_sx() { + // Measurement error produces S_X on the measured qubit + let gates = vec![gate(GateType::MZ, &[0])]; + let noise = NoiseModel { + idle_rz: 0.0, + p1: 0.0, + p2: 0.0, + p_meas: 0.05, + p_prep: 0.0, + }; + let result = analyze_expanded(&gates, &noise); + + let s_gens: Vec<_> = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::S) + .collect(); + assert_eq!(s_gens.len(), 1); + assert_eq!(s_gens[0].label, Bm::x(0)); + assert!((s_gens[0].coeff + 0.05).abs() < 1e-10); + } + + #[test] + fn test_prep_noise_injects_sx() { + // Preparation error: S_X after PZ + let gates = vec![gate(GateType::PZ, &[0])]; + let noise = NoiseModel { + idle_rz: 0.0, + p1: 0.0, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.03, + }; + let result = analyze_expanded(&gates, &noise); + + let s_gens: Vec<_> = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::S) + .collect(); + assert_eq!(s_gens.len(), 1); + assert!((s_gens[0].coeff + 0.03).abs() < 1e-10); + } + + #[test] + fn test_expansion_pz_gets_no_prep_noise() { + // Expansion PZ (measurement projection) should NOT inject prep noise. + // Circuit: PZ(0,1), H(1), CX(1,0), MZ(1), PZ(1), H(1), CX(1,0), MZ(1), MZ(0) + // With p_prep > 0: only the original PZ gates should inject noise. + let original_gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::H, &[1]), + gate(GateType::CX, &[1, 0]), + gate(GateType::MZ, &[1]), + gate(GateType::PZ, &[1]), // original reset + gate(GateType::H, &[1]), + gate(GateType::CX, &[1, 0]), + gate(GateType::MZ, &[1]), // last round + gate(GateType::MZ, &[0]), + ]; + let expanded = crate::expand::expand_circuit(&original_gates); + + // Count PZ gates in expanded circuit (originals + expansion projections) + let all_pz: Vec<_> = expanded + .gates + .iter() + .filter(|g| g.gate_type == GateType::PZ) + .collect(); + + // With prep noise: count S generators from prep + let noise = NoiseModel { + idle_rz: 0.0, + p1: 0.0, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.1, + }; + let result = analyze_expanded(&expanded.gates, &noise); + + let prep_gens: Vec<_> = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::S) + .collect(); + + // Original PZ gates: PZ(0) init, PZ(1) init, PZ(1) reset = 3 PZ with noise + // Expansion PZ: should NOT inject noise + // Each original PZ injects 1 S generator (S_X) + assert_eq!( + prep_gens.len(), + 3, + "Only original PZ should inject prep noise, not expansion PZ. \ + Got {} S generators, total PZ gates in expanded: {}", + prep_gens.len(), + all_pz.len() + ); + } + + #[test] + fn test_expansion_cx_gets_no_noise() { + // The CX gates added by expansion (deferred measurement) should not get noise. + // Circuit: PZ(0), CX(0,1), MZ(0), MZ(1) + // Expanded: PZ(0), CX(0,1), QAlloc(2), CX(0,2), QAlloc(3), CX(1,3), MZ(2), MZ(3) + // Only CX(0,1) should get noise, not CX(0,2) or CX(1,3). + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::CX, &[0, 1]), + gate(GateType::MZ, &[0]), + gate(GateType::MZ, &[1]), + ]; + let expanded = crate::expand::expand_circuit(&gates); + let noise = NoiseModel::coherent_only(0.1); + let result = analyze_expanded(&expanded.gates, &noise); + + let h_gens: Vec<_> = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::H) + .collect(); + // Only 2 H generators from the original CX (one per qubit) + assert_eq!( + h_gens.len(), + 2, + "Only original CX should get noise, not expansion CX gates" + ); + } +} diff --git a/exp/pecos-eeg/src/coherent_dem.rs b/exp/pecos-eeg/src/coherent_dem.rs new file mode 100644 index 000000000..a3bf21ed5 --- /dev/null +++ b/exp/pecos-eeg/src/coherent_dem.rs @@ -0,0 +1,909 @@ +// 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. + +//! Coherent DEM builder via backward Heisenberg mechanism extraction. +//! +//! Walks each noise source backward through the circuit to determine its +//! effective Pauli label at each detector. Groups noise sources by their +//! effective label (= same DEM mechanism), accumulates coherent amplitudes, +//! and computes mechanism probabilities. +//! +//! For H-type (coherent) noise: amplitudes add, probability = sin²(total). +//! For S-type (stochastic) noise: rates add, probability = (1-exp(2·total))/2. + +use crate::Bm; +use crate::dem_mapping::{DecomposableDemEntry, DemEntry, DemEvent, Detector, Observable}; +use crate::eeg::EegType; +use crate::heisenberg::{SparsePauli, sparse_conjugate}; +use crate::noise::NoiseSpec; +use pecos_core::Gate; +use pecos_core::pauli::pauli_bitmask::BitmaskStorage; +use smallvec::SmallVec; +use std::collections::BTreeMap; + +type FittedEventKey = (SmallVec<[usize; 4]>, SmallVec<[usize; 2]>); + +/// A noise contribution at a specific gate. +struct NoiseContribution { + /// Effective Pauli label after backward propagation. + label: Bm, + /// EEG type (H or S). + eeg_type: EegType, + /// Amplitude or rate. + value: f64, +} + +/// Build a coherent DEM by extracting mechanisms from backward propagation. +/// +/// For each noise injection point in the expanded circuit, propagates +/// its Pauli label backward to the detector measurement point. Noise +/// sources that produce the same effective Pauli label are grouped into +/// a single DEM mechanism with coherently accumulated amplitude. +/// +/// This gives both correct mechanism structure AND correct coherent +/// probabilities from a single framework. +pub fn build_coherent_dem( + gates: &[Gate], + noise: &dyn NoiseSpec, + detectors: &[Detector], + observables: &[Observable], + expansion_gates: &[bool], +) -> Vec { + // Step 1: Collect all noise sources and their Pauli labels + let mut noise_sources: Vec<(usize, NoiseContribution)> = Vec::new(); + + for (gate_idx, gate) in gates.iter().enumerate() { + if gate_idx < expansion_gates.len() && expansion_gates[gate_idx] { + continue; + } + let qubits: SmallVec<[usize; 4]> = + gate.qubits.iter().map(pecos_core::QubitId::index).collect(); + let injections = noise.noise_after_gate(gate_idx, gate.gate_type, &qubits); + + for inj in injections { + noise_sources.push(( + gate_idx, + NoiseContribution { + label: inj.label.clone(), + eeg_type: inj.eeg_type, + value: inj.rate, + }, + )); + } + } + + // Step 2: For each noise source, determine which detectors it affects + // by checking if its Pauli label, after backward propagation through + // the circuit, anticommutes with each detector's stabilizer. + // + // Rather than propagating each noise label forward (expensive), we use + // the detectors' stabilizers propagated backward to each noise location. + // For each detector, we run the backward Heisenberg walk and at each + // noise source check anticommutation. If the backward-propagated + // stabilizer anticommutes with the noise label at that gate, the noise + // source affects that detector. + // + // We compute this per-detector, then group noise sources by which + // detectors they affect. + + // For each noise source: which detectors and observables it flips + let num_noise = noise_sources.len(); + let mut noise_det_sets: Vec> = vec![SmallVec::new(); num_noise]; + let mut noise_obs_sets: Vec> = vec![SmallVec::new(); num_noise]; + + // Build gate_index -> noise_source_indices map (shared across all walks) + let mut gate_to_noise: BTreeMap> = BTreeMap::new(); + for (ns_idx, (gate_idx, _)) in noise_sources.iter().enumerate() { + gate_to_noise.entry(*gate_idx).or_default().push(ns_idx); + } + + // Helper: propagate a stabilizer/observable backward and record + // which noise sources anticommute with it. + let backward_classify = |stabilizer: &Bm| -> Vec { + let mut prop = stabilizer.clone(); + let mut hits = vec![false; num_noise]; + + for gate_idx in (0..gates.len()).rev() { + // Check noise sources BEFORE undoing the gate + if let Some(ns_indices) = gate_to_noise.get(&gate_idx) { + for &ns_idx in ns_indices { + if !prop.commutes_with(&noise_sources[ns_idx].1.label) { + hits[ns_idx] = true; + } + } + } + backward_conjugate_bm(&mut prop, &gates[gate_idx]); + } + + hits + }; + + // Classify: which detectors does each noise source flip? + for det in detectors { + let hits = backward_classify(&det.stabilizer); + for (ns_idx, &hit) in hits.iter().enumerate() { + if hit { + noise_det_sets[ns_idx].push(det.id); + } + } + } + + // Classify: which observables does each noise source flip? + for obs in observables { + let hits = backward_classify(&obs.pauli); + for (ns_idx, &hit) in hits.iter().enumerate() { + if hit { + noise_obs_sets[ns_idx].push(obs.id); + } + } + } + + // Step 3: Group noise sources by (detector_set, observable_set, eeg_type, label). + // + // For H-type: only noise sources with the SAME Pauli label accumulate + // coherently. Different labels (e.g., Z on qubit 1 vs Z on qubit 2) + // are separate mechanisms even if they flip the same detectors. + // + // For S-type: same grouping — different Pauli types at the same location + // are independent mechanisms. + // + // After coherent accumulation per label, mechanisms with the same + // detector set are combined independently (product formula). + let mut h_groups: BTreeMap<(DemEvent, Bm), f64> = BTreeMap::new(); + let mut s_groups: BTreeMap<(DemEvent, Bm), f64> = BTreeMap::new(); + + for (ns_idx, (_, contrib)) in noise_sources.iter().enumerate() { + let dets = &noise_det_sets[ns_idx]; + let obs = &noise_obs_sets[ns_idx]; + if dets.is_empty() && obs.is_empty() { + continue; + } + + let event = DemEvent { + detectors: dets.clone(), + observables: obs.clone(), + }; + + let key = (event, contrib.label.clone()); + match contrib.eeg_type { + EegType::H => { + *h_groups.entry(key).or_insert(0.0) += contrib.value; + } + EegType::S => { + *s_groups.entry(key).or_insert(0.0) += contrib.value; + } + _ => {} + } + } + + // Step 4: Compute approximate probabilities per mechanism + let mut entries = Vec::new(); + + for ((event, _label), total_h) in &h_groups { + let prob = total_h.sin().powi(2); + if prob > 1e-15 { + entries.push(DemEntry { + event: event.clone(), + probability: prob, + }); + } + } + + for ((event, _label), total_s) in &s_groups { + let prob = (1.0 - (2.0 * total_s).exp()) / 2.0; + if prob.abs() > 1e-15 { + entries.push(DemEntry { + event: event.clone(), + probability: prob.abs(), + }); + } + } + + merge_dem_entries(entries) +} + +/// Build a coherent DEM with X/Z decomposition info for MWPM decoders. +/// +/// Same mechanism extraction as `build_coherent_dem`, but additionally +/// splits each Pauli label into X-only and Z-only components and checks +/// anticommutation separately. This produces `DecomposableDemEntry`s +/// that know which detectors each component flips, enabling proper +/// graphlike decomposition for pymatching. +pub fn build_coherent_dem_decomposable( + gates: &[Gate], + noise: &dyn NoiseSpec, + detectors: &[Detector], + observables: &[Observable], + expansion_gates: &[bool], +) -> Vec { + // Step 1: Collect noise sources (same as build_coherent_dem) + let mut noise_sources: Vec<(usize, NoiseContribution)> = Vec::new(); + + for (gate_idx, gate) in gates.iter().enumerate() { + if gate_idx < expansion_gates.len() && expansion_gates[gate_idx] { + continue; + } + let qubits: SmallVec<[usize; 4]> = + gate.qubits.iter().map(pecos_core::QubitId::index).collect(); + let injections = noise.noise_after_gate(gate_idx, gate.gate_type, &qubits); + + for inj in injections { + noise_sources.push(( + gate_idx, + NoiseContribution { + label: inj.label.clone(), + eeg_type: inj.eeg_type, + value: inj.rate, + }, + )); + } + } + + let num_noise = noise_sources.len(); + + // For each noise source: full, X-only, and Z-only detector/observable sets + let mut noise_det_sets: Vec> = vec![SmallVec::new(); num_noise]; + let mut noise_obs_sets: Vec> = vec![SmallVec::new(); num_noise]; + let mut noise_x_det_sets: Vec> = vec![SmallVec::new(); num_noise]; + let mut noise_x_obs_sets: Vec> = vec![SmallVec::new(); num_noise]; + let mut noise_z_det_sets: Vec> = vec![SmallVec::new(); num_noise]; + let mut noise_z_obs_sets: Vec> = vec![SmallVec::new(); num_noise]; + + // Precompute X-only and Z-only labels for each noise source + let noise_x_labels: Vec = noise_sources + .iter() + .map(|(_, c)| Bm { + x_bits: c.label.x_bits.clone(), + ..Default::default() + }) + .collect(); + let noise_z_labels: Vec = noise_sources + .iter() + .map(|(_, c)| Bm { + z_bits: c.label.z_bits.clone(), + ..Default::default() + }) + .collect(); + + // Build gate -> noise source index map + let mut gate_to_noise: BTreeMap> = BTreeMap::new(); + for (ns_idx, (gate_idx, _)) in noise_sources.iter().enumerate() { + gate_to_noise.entry(*gate_idx).or_default().push(ns_idx); + } + + // Step 2: Backward walk — check anticommutation with full, X-only, Z-only labels + let backward_classify_xz = |stabilizer: &Bm| -> (Vec, Vec, Vec) { + let mut prop = stabilizer.clone(); + let mut hits_full = vec![false; num_noise]; + let mut hits_x = vec![false; num_noise]; + let mut hits_z = vec![false; num_noise]; + + for gate_idx in (0..gates.len()).rev() { + if let Some(ns_indices) = gate_to_noise.get(&gate_idx) { + for &ns_idx in ns_indices { + // Full anticommutation + if !prop.commutes_with(&noise_sources[ns_idx].1.label) { + hits_full[ns_idx] = true; + } + // X-only: ⟨S, P_X⟩ = S_Z · P_X + // P_X has no Z bits, so anticommutation only from S_Z * P_X + if !noise_x_labels[ns_idx].x_bits.is_zero() + && !prop.commutes_with(&noise_x_labels[ns_idx]) + { + hits_x[ns_idx] = true; + } + // Z-only: ⟨S, P_Z⟩ = S_X · P_Z + // P_Z has no X bits, so anticommutation only from S_X * P_Z + if !noise_z_labels[ns_idx].z_bits.is_zero() + && !prop.commutes_with(&noise_z_labels[ns_idx]) + { + hits_z[ns_idx] = true; + } + } + } + backward_conjugate_bm(&mut prop, &gates[gate_idx]); + } + + (hits_full, hits_x, hits_z) + }; + + // Classify detectors + for det in detectors { + let (hits_full, hits_x, hits_z) = backward_classify_xz(&det.stabilizer); + for ns_idx in 0..num_noise { + if hits_full[ns_idx] { + noise_det_sets[ns_idx].push(det.id); + } + if hits_x[ns_idx] { + noise_x_det_sets[ns_idx].push(det.id); + } + if hits_z[ns_idx] { + noise_z_det_sets[ns_idx].push(det.id); + } + } + } + + // Classify observables + for obs in observables { + let (hits_full, hits_x, hits_z) = backward_classify_xz(&obs.pauli); + for ns_idx in 0..num_noise { + if hits_full[ns_idx] { + noise_obs_sets[ns_idx].push(obs.id); + } + if hits_x[ns_idx] { + noise_x_obs_sets[ns_idx].push(obs.id); + } + if hits_z[ns_idx] { + noise_z_obs_sets[ns_idx].push(obs.id); + } + } + } + + // Step 3: Group by (event, label, eeg_type) — same as build_coherent_dem + // but also track X/Z component events + let mut h_groups: BTreeMap<(DemEvent, Bm), (f64, DemEvent, DemEvent)> = BTreeMap::new(); + let mut s_groups: BTreeMap<(DemEvent, Bm), (f64, DemEvent, DemEvent)> = BTreeMap::new(); + + for (ns_idx, (_, contrib)) in noise_sources.iter().enumerate() { + let dets = &noise_det_sets[ns_idx]; + let obs = &noise_obs_sets[ns_idx]; + if dets.is_empty() && obs.is_empty() { + continue; + } + + let event = DemEvent { + detectors: dets.clone(), + observables: obs.clone(), + }; + let x_event = DemEvent { + detectors: noise_x_det_sets[ns_idx].clone(), + observables: noise_x_obs_sets[ns_idx].clone(), + }; + let z_event = DemEvent { + detectors: noise_z_det_sets[ns_idx].clone(), + observables: noise_z_obs_sets[ns_idx].clone(), + }; + + let key = (event.clone(), contrib.label.clone()); + let groups = match contrib.eeg_type { + EegType::H => &mut h_groups, + EegType::S => &mut s_groups, + _ => continue, + }; + groups + .entry(key) + .and_modify(|(val, _, _)| *val += contrib.value) + .or_insert((contrib.value, x_event, z_event)); + } + + // Step 4: Compute probabilities with decomposition info + let mut entries = Vec::new(); + + for ((event, _label), (total_h, x_ev, z_ev)) in &h_groups { + let prob = total_h.sin().powi(2); + if prob > 1e-15 { + let has_x = !x_ev.detectors.is_empty() || !x_ev.observables.is_empty(); + let has_z = !z_ev.detectors.is_empty() || !z_ev.observables.is_empty(); + entries.push(DecomposableDemEntry { + event: event.clone(), + probability: prob, + x_component: if has_x { Some(x_ev.clone()) } else { None }, + z_component: if has_z { Some(z_ev.clone()) } else { None }, + }); + } + } + + for ((event, _label), (total_s, x_ev, z_ev)) in &s_groups { + let prob = (1.0 - (2.0 * total_s).exp()) / 2.0; + if prob.abs() > 1e-15 { + let has_x = !x_ev.detectors.is_empty() || !x_ev.observables.is_empty(); + let has_z = !z_ev.detectors.is_empty() || !z_ev.observables.is_empty(); + entries.push(DecomposableDemEntry { + event: event.clone(), + probability: prob.abs(), + x_component: if has_x { Some(x_ev.clone()) } else { None }, + z_component: if has_z { Some(z_ev.clone()) } else { None }, + }); + } + } + + // Merge entries with identical combined events + merge_decomposable_dem_entries(entries) +} + +fn merge_decomposable_dem_entries( + mut entries: Vec, +) -> Vec { + if entries.len() <= 1 { + return entries; + } + + // Sort by combined event + entries.sort_by(|a, b| { + a.event + .detectors + .cmp(&b.event.detectors) + .then(a.event.observables.cmp(&b.event.observables)) + }); + + let mut merged = Vec::new(); + let mut i = 0; + while i < entries.len() { + let mut entry = entries[i].clone(); + let mut j = i + 1; + while j < entries.len() + && entries[j].event.detectors == entry.event.detectors + && entries[j].event.observables == entry.event.observables + { + // Independent combination: p = p1 + p2 - 2*p1*p2 + entry.probability = entry.probability + entries[j].probability + - 2.0 * entry.probability * entries[j].probability; + // Keep X/Z components from first entry (they should be consistent + // for same-event mechanisms, or we take the first as representative) + j += 1; + } + merged.push(entry); + i = j; + } + + merged +} + +/// Build a coherent DEM with Heisenberg-exact marginals. +/// +/// Uses the backward mechanism extraction for structure (which detectors +/// each noise source flips) and fits mechanism probabilities to match +/// Heisenberg-exact per-detector marginal rates. +/// +/// This combines: +/// - Correct mechanism structure from backward propagation +/// - Exact marginals from the Heisenberg walk +/// - Best independent approximation via iterative fitting +/// +/// The `heisenberg_marginals` parameter should be a slice where +/// `heisenberg_marginals[det_id] = exact_detection_probability`. +/// +/// The optional `heisenberg_pairwise` parameter gives exact joint rates +/// P(Di AND Dj) for detector pairs. When provided, the fit also matches +/// pairwise correlations, significantly improving 2-body and 3-body accuracy. +/// Each entry is `((det_i, det_j), joint_probability)`. +pub fn build_coherent_dem_exact( + gates: &[Gate], + noise: &dyn NoiseSpec, + detectors: &[Detector], + observables: &[Observable], + expansion_gates: &[bool], + heisenberg_marginals: &[f64], + heisenberg_pairwise: Option<&[((usize, usize), f64)]>, +) -> Vec { + // Step 1-3: Get mechanism structure (same as approximate version) + let approx = build_coherent_dem(gates, noise, detectors, observables, expansion_gates); + + if approx.is_empty() { + return approx; + } + + let num_dets = heisenberg_marginals.len(); + + // Build incidence: for each detector, which mechanisms affect it + let mut det_to_mechs: Vec> = vec![Vec::new(); num_dets]; + for (m, entry) in approx.iter().enumerate() { + for &d in &entry.event.detectors { + if d < num_dets { + det_to_mechs[d].push(m); + } + } + } + + // Extract initial probabilities + let mut q: Vec = approx.iter().map(|e| e.probability).collect(); + let n_mech = q.len(); + + // Precompute mechanism sets for pairwise computation + let det_mech_sets: Vec> = (0..num_dets) + .map(|d| det_to_mechs[d].iter().copied().collect()) + .collect(); + + // Compute DEM marginal for detector d + let compute_marginal = |q: &[f64], d: usize| -> f64 { + let mut prod = 1.0; + for &m in &det_to_mechs[d] { + prod *= 1.0 - 2.0 * q[m]; + } + (1.0 - prod) / 2.0 + }; + + // L-BFGS optimization in sigmoid-parameterized space. + // + // Parameterize q_m = 0.499 * sigmoid(x_m) so x_m is unconstrained. + // This gives a smooth loss landscape that L-BFGS can navigate efficiently. + // + // Loss = sum_d (marginal_d - target_d)^2 + // + sum_pairs (pairwise_ij - target_ij)^2 + let pairs: Vec<((usize, usize), f64)> = heisenberg_pairwise + .map(<[((usize, usize), f64)]>::to_vec) + .unwrap_or_default(); + let has_pairwise = !pairs.is_empty(); + + // Initialize x from q: x = logit(q / 0.499) + let mut x: Vec = q + .iter() + .map(|&qi| { + let s = (qi / 0.499).clamp(1e-10, 1.0 - 1e-10); + (s / (1.0 - s)).ln() + }) + .collect(); + + let sigmoid = |xi: f64| -> f64 { 0.499 / (1.0 + (-xi).exp()) }; + let sigmoid_deriv = |xi: f64| -> f64 { + let s = 1.0 / (1.0 + (-xi).exp()); + 0.499 * s * (1.0 - s) + }; + + // Compute loss and gradient in x-space + let compute_loss_grad = |x: &[f64]| -> (f64, Vec) { + let q_local: Vec = x.iter().map(|&xi| sigmoid(xi)).collect(); + let dq_dx: Vec = x.iter().map(|&xi| sigmoid_deriv(xi)).collect(); + + let mut grad_q = vec![0.0_f64; n_mech]; + let mut loss = 0.0_f64; + + // Marginal terms + for d in 0..num_dets { + let current_d = compute_marginal(&q_local, d); + let residual = current_d - heisenberg_marginals[d]; + loss += residual * residual; + + let mut full_prod = 1.0; + for &m in &det_to_mechs[d] { + full_prod *= 1.0 - 2.0 * q_local[m]; + } + for &m in &det_to_mechs[d] { + let factor = 1.0 - 2.0 * q_local[m]; + if factor.abs() > 1e-30 { + grad_q[m] += 2.0 * residual * full_prod / factor; + } + } + } + + // Pairwise terms + if has_pairwise { + let full_prods: Vec = (0..num_dets) + .map(|d| { + let mut p = 1.0; + for &m in &det_to_mechs[d] { + p *= 1.0 - 2.0 * q_local[m]; + } + p + }) + .collect(); + + for &((di, dj), target_p) in &pairs { + if di >= num_dets || dj >= num_dets || target_p < 1e-10 { + continue; + } + + let prod_i = full_prods[di]; + let prod_j = full_prods[dj]; + let mut prod_both = 1.0; + for &m in det_mech_sets[di].intersection(&det_mech_sets[dj]) { + prod_both *= 1.0 - 2.0 * q_local[m]; + } + let prod_xor = if prod_both.abs() > 1e-30 { + prod_i * prod_j / (prod_both * prod_both) + } else { + 0.0 + }; + + let current_p = (1.0 - prod_i - prod_j + prod_xor) / 4.0; + let residual = current_p - target_p; + loss += residual * residual; + + for &m in det_mech_sets[di].intersection(&det_mech_sets[dj]) { + let factor = 1.0 - 2.0 * q_local[m]; + if factor.abs() > 1e-30 { + grad_q[m] += 2.0 * residual * (prod_i + prod_j) / (2.0 * factor); + } + } + for &m in &det_to_mechs[di] { + if !det_mech_sets[dj].contains(&m) { + let factor = 1.0 - 2.0 * q_local[m]; + if factor.abs() > 1e-30 { + grad_q[m] += 2.0 * residual * (prod_i - prod_xor) / (2.0 * factor); + } + } + } + for &m in &det_to_mechs[dj] { + if !det_mech_sets[di].contains(&m) { + let factor = 1.0 - 2.0 * q_local[m]; + if factor.abs() > 1e-30 { + grad_q[m] += 2.0 * residual * (prod_j - prod_xor) / (2.0 * factor); + } + } + } + } + } + + // Chain rule: grad_x = grad_q * dq/dx + let grad_x: Vec = grad_q + .iter() + .zip(dq_dx.iter()) + .map(|(&gq, &dx)| gq * dx) + .collect(); + + (loss, grad_x) + }; + + // L-BFGS two-loop recursion + let m_lbfgs = 10; // history size + let mut s_hist: Vec> = Vec::new(); // x differences + let mut y_hist: Vec> = Vec::new(); // gradient differences + let mut rho_hist: Vec = Vec::new(); + + let (mut loss, mut grad) = compute_loss_grad(&x); + + for _iter in 0..500 { + if loss < 1e-14 { + break; + } + + // L-BFGS direction: H_k * grad + let mut direction = grad.clone(); + + // Two-loop recursion + let hist_len = s_hist.len(); + let mut alpha = vec![0.0; hist_len]; + for i in (0..hist_len).rev() { + alpha[i] = rho_hist[i] * dot(&s_hist[i], &direction); + for j in 0..n_mech { + direction[j] -= alpha[i] * y_hist[i][j]; + } + } + // Scale by gamma = s'y / y'y from most recent pair + if let (Some(s), Some(y)) = (s_hist.last(), y_hist.last()) { + let yy = dot(y, y); + if yy > 1e-30 { + let gamma = dot(s, y) / yy; + for d in &mut direction { + *d *= gamma; + } + } + } + for i in 0..hist_len { + let beta = rho_hist[i] * dot(&y_hist[i], &direction); + for j in 0..n_mech { + direction[j] += (alpha[i] - beta) * s_hist[i][j]; + } + } + + // Negate for descent direction + for d in &mut direction { + *d = -*d; + } + + // Backtracking line search (Armijo condition) + let dg = dot(&grad, &direction); + if dg >= 0.0 { + break; + } // not a descent direction + + let mut step = 1.0; + let c1 = 1e-4; + let mut x_new: Vec = x + .iter() + .zip(direction.iter()) + .map(|(&xi, &di)| xi + step * di) + .collect(); + let (mut loss_new, mut grad_new) = compute_loss_grad(&x_new); + + for _ in 0..20 { + if loss_new <= loss + c1 * step * dg { + break; + } + step *= 0.5; + x_new = x + .iter() + .zip(direction.iter()) + .map(|(&xi, &di)| xi + step * di) + .collect(); + let (ln, gn) = compute_loss_grad(&x_new); + loss_new = ln; + grad_new = gn; + } + + // Update L-BFGS history + let s_k: Vec = x_new.iter().zip(x.iter()).map(|(&a, &b)| a - b).collect(); + let y_k: Vec = grad_new + .iter() + .zip(grad.iter()) + .map(|(&a, &b)| a - b) + .collect(); + let sy = dot(&s_k, &y_k); + if sy > 1e-30 { + if s_hist.len() >= m_lbfgs { + s_hist.remove(0); + y_hist.remove(0); + rho_hist.remove(0); + } + s_hist.push(s_k); + y_hist.push(y_k); + rho_hist.push(1.0 / sy); + } + + x = x_new; + loss = loss_new; + grad = grad_new; + } + + // Convert back to q + for (m, &xi) in x.iter().enumerate() { + q[m] = sigmoid(xi); + } + + // Build fitted DEM entries + let fitted: Vec = approx + .iter() + .zip(q.iter()) + .filter(|(_, p)| **p > 1e-15) + .map(|(entry, p)| DemEntry { + event: entry.event.clone(), + probability: *p, + }) + .collect(); + + merge_dem_entries(fitted) +} + +/// Build a coherent DEM with Heisenberg-exact marginals AND X/Z decomposition. +/// +/// Combines the exact probability fitting from `build_coherent_dem_exact` +/// with the X/Z component tracking from `build_coherent_dem_decomposable`. +pub fn build_coherent_dem_exact_decomposable( + gates: &[Gate], + noise: &dyn NoiseSpec, + detectors: &[Detector], + observables: &[Observable], + expansion_gates: &[bool], + heisenberg_marginals: &[f64], + heisenberg_pairwise: Option<&[((usize, usize), f64)]>, +) -> Vec { + // Get X/Z component structure from decomposable builder + let decomposable = + build_coherent_dem_decomposable(gates, noise, detectors, observables, expansion_gates); + + if decomposable.is_empty() { + return decomposable; + } + + // Get fitted probabilities from exact builder + let fitted = build_coherent_dem_exact( + gates, + noise, + detectors, + observables, + expansion_gates, + heisenberg_marginals, + heisenberg_pairwise, + ); + + // Build lookup: event → fitted probability + let mut prob_lookup: BTreeMap = BTreeMap::new(); + for entry in &fitted { + prob_lookup.insert( + ( + entry.event.detectors.clone(), + entry.event.observables.clone(), + ), + entry.probability, + ); + } + + // Combine: X/Z structure from decomposable + fitted probabilities from exact + decomposable + .into_iter() + .filter_map(|mut entry| { + let key = ( + entry.event.detectors.clone(), + entry.event.observables.clone(), + ); + if let Some(&fitted_prob) = prob_lookup.get(&key) { + entry.probability = fitted_prob; + Some(entry) + } else if entry.probability > 1e-15 { + // Keep original probability if no fitted version (edge case) + Some(entry) + } else { + None + } + }) + .collect() +} + +/// Merge DEM entries with the same event via independent combination. +fn merge_dem_entries(mut entries: Vec) -> Vec { + entries.sort_by(|a, b| a.event.cmp(&b.event)); + let mut merged = Vec::new(); + for entry in entries { + if let Some(last) = merged.last_mut() { + let last: &mut DemEntry = last; + if last.event == entry.event { + let p1 = last.probability; + let p2 = entry.probability; + last.probability = p1 + p2 - 2.0 * p1 * p2; + continue; + } + } + merged.push(entry); + } + merged +} + +/// Dot product of two slices. +#[inline] +fn dot(a: &[f64], b: &[f64]) -> f64 { + a.iter().zip(b.iter()).map(|(&x, &y)| x * y).sum() +} + +/// Backward-conjugate a Bm stabilizer through a gate (Heisenberg picture). +/// +/// Converts to SparsePauli, uses the tested sparse_conjugate function +/// (which already handles adjoint swapping for backward direction), +/// then converts back. Panics on unsupported gates. +fn backward_conjugate_bm(prop: &mut Bm, gate: &Gate) { + use pecos_core::gate_type::GateType; + match gate.gate_type { + // Prep/alloc: kill the Pauli on the prepared qubit. + // Z-basis prep projects onto |0>, destroying X coherences. + // Backward propagation stops here — errors before prep + // don't affect measurements after it. + GateType::PZ | GateType::QAlloc => { + for q in &gate.qubits { + let qi = q.index(); + // Clear both X and Z on this qubit + if prop.has_x(qi) { + let mut sp = SparsePauli::from_bm(prop); + sp.clear_x(qi as u16); + *prop = sp.to_bm(); + } + if prop.has_z(qi) { + let mut sp = SparsePauli::from_bm(prop); + sp.clear_z(qi as u16); + *prop = sp.to_bm(); + } + } + return; + } + // Measurement: kill X on measured qubit (Z-basis measurement + // is insensitive to Z errors, but X errors flip the result). + // For backward propagation, we don't propagate X past MZ. + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked => { + for q in &gate.qubits { + let qi = q.index(); + if prop.has_x(qi) { + let mut sp = SparsePauli::from_bm(prop); + sp.clear_x(qi as u16); + *prop = sp.to_bm(); + } + } + return; + } + GateType::QFree | GateType::I | GateType::Idle => return, + _ => {} + } + + let mut sp = SparsePauli::from_bm(prop); + // sparse_conjugate already applies adjoint swap for backward walk + let _sign = sparse_conjugate(&mut sp, gate); + // Sign is tracked in the Heisenberg walk's coefficients; + // for the Bm-level classification we only need the Pauli structure. + *prop = sp.to_bm(); +} diff --git a/exp/pecos-eeg/src/correlation_table.rs b/exp/pecos-eeg/src/correlation_table.rs new file mode 100644 index 000000000..ed42f237d --- /dev/null +++ b/exp/pecos-eeg/src/correlation_table.rs @@ -0,0 +1,406 @@ +// 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. + +//! Exact detector correlation tables from backward Heisenberg walks. +//! +//! Computes exact k-body joint detection rates using product stabilizer +//! walks. No DEM approximation — captures all coherent interference. +//! +//! For k detectors with stabilizers S1..Sk, the joint detection probability +//! is computed via inclusion-exclusion: +//! +//! P(D1 AND D2 AND ... AND Dk) = 1/2^k * sum_{T ⊆ {1..k}} (-1)^{k-|T|} +//! +//! where is computed by a Heisenberg walk with the product stabilizer. +//! +//! Each walk gives one expectation value. The number of walks needed: +//! - Order 1 (marginals): C(n,1) = n +//! - Order 2 (pairwise): C(n,2) new walks +//! - Order 3 (triples): C(n,3) new walks +//! - Total up to order k: sum_{j=1}^{k} C(n,j) + +use crate::Bm; +use crate::dem_mapping::{Detector, Observable}; +use crate::noise::NoiseSpec; +use crate::stabilizer::StabilizerGroup; +use pecos_core::Gate; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use std::collections::BTreeMap; +use std::fmt::Write as _; + +/// Exact k-body correlation table for detectors and observables. +/// +/// Contains two types of correlations: +/// - **Detector rates**: P(D_i1, ..., D_ik) — joint detection probabilities +/// - **Detector-observable rates**: P(D_i1, ..., D_ik, L_j) — how detection +/// patterns relate to logical observable flips (what decoders need) +pub struct CorrelationTable { + /// Detector-only joint rates: sorted det_indices -> probability + pub rates: BTreeMap, f64>, + /// Detector-observable joint rates: (sorted det_indices, obs_id) -> probability. + /// P(D_i1 AND ... AND D_ik AND L_j) for each observable j. + pub observable_rates: BTreeMap<(Vec, usize), f64>, + /// Maximum correlation order computed (for detectors) + pub max_order: usize, + /// Number of detectors + pub num_detectors: usize, + /// Number of observables + pub num_observables: usize, + /// Number of Heisenberg walks performed + pub num_walks: usize, +} + +/// Inputs for exact correlation table construction. +#[derive(Clone, Copy)] +pub struct CorrelationTableInput<'a> { + /// Circuit gates. + pub gates: &'a [Gate], + /// Noise model used for exact correlation targets. + pub noise: &'a dyn NoiseSpec, + /// Detector definitions. + pub detectors: &'a [Detector], + /// Observable definitions. + pub observables: &'a [Observable], + /// Initial stabilizer group. + pub initial_stab: &'a StabilizerGroup, + /// Number of circuit qubits. + pub num_qubits: usize, + /// Maximum detector/observable correlation order. + pub max_order: usize, + /// Drop probabilities below this threshold. + pub prune_threshold: f64, +} + +impl CorrelationTable { + /// Build a graphlike DEM string from the correlation table. + /// + /// Uses pairwise correlations as edge probabilities for MWPM decoders. + /// Each pairwise correlation P(Di AND Dj) - P(Di)*P(Dj) becomes an edge. + /// Observable assignment uses P(Di AND Lk) rates. + /// + /// This bypasses the DEM independent error model — edge weights come + /// directly from exact Heisenberg correlations including all coherent + /// interference effects. + #[must_use] + pub fn to_matching_dem(&self) -> String { + let mut lines = Vec::new(); + + // Get marginals + let marginals: BTreeMap = self + .rates + .iter() + .filter(|(k, _)| k.len() == 1) + .map(|(k, &v)| (k[0], v)) + .collect(); + + // Observable marginals per detector: P(Di AND Lk) + let mut det_obs: BTreeMap<(usize, usize), f64> = BTreeMap::new(); + for ((det_ids, obs_id), &prob) in &self.observable_rates { + if det_ids.len() == 1 { + det_obs.insert((det_ids[0], *obs_id), prob); + } + } + + // Pairwise edges: excess correlation = P(Di,Dj) - P(Di)*P(Dj) + for (key, &joint_prob) in &self.rates { + if key.len() != 2 { + continue; + } + let (di, dj) = (key[0], key[1]); + + let pi = marginals.get(&di).copied().unwrap_or(0.0); + let pj = marginals.get(&dj).copied().unwrap_or(0.0); + let p_excess = joint_prob - pi * pj; + + if p_excess <= 1e-15 { + continue; + } // no positive correlation + let p_edge = p_excess.min(0.499); // clamp for valid weight + + // Determine observable assignment: which Lk is most correlated + // with this pair? Use P(Di AND Dj AND Lk) if available, + // otherwise no observable. + let mut obs_list = Vec::new(); + for obs_id in 0..self.num_observables { + let pair_key = (vec![di, dj], obs_id); + if let Some(&p_trio) = self.observable_rates.get(&pair_key) { + // If the trio rate is significant relative to the pair rate, + // this observable is correlated with this edge + if p_trio > joint_prob * 0.1 { + obs_list.push(obs_id); + } + } + } + + let mut targets = format!("D{di} D{dj}"); + for o in &obs_list { + let _ = write!(targets, " L{o}"); + } + lines.push(format!("error({p_edge:.6e}) {targets}")); + } + + // Boundary edges: P(Di AND Lk) - P(Di)*P(Lk) + // Approximation: use P(Di AND Lk) directly as boundary edge probability + // (represents probability Di fires due to a logical error chain) + for obs_id in 0..self.num_observables { + for di in 0..self.num_detectors { + let p_det_obs = det_obs.get(&(di, obs_id)).copied().unwrap_or(0.0); + + // Check if this detector has significant correlation with the observable + // that isn't already explained by pairwise edges + if p_det_obs <= 1e-15 { + continue; + } + + // Boundary probability: P(Di fires AND it's a logical error) + let p_boundary = p_det_obs.min(0.499); + if p_boundary <= 1e-15 { + continue; + } + + lines.push(format!("error({p_boundary:.6e}) D{di} L{obs_id}")); + } + } + + lines.join("\n") + } +} + +/// Compute exact correlation table up to `max_order` using Heisenberg walks. +/// +/// Each entry gives the exact joint detection probability for a subset of +/// detectors, including all coherent interference effects. +#[must_use] +pub fn compute_correlation_table(input: CorrelationTableInput<'_>) -> CorrelationTable { + let CorrelationTableInput { + gates, + noise, + detectors, + observables, + initial_stab, + num_qubits, + max_order, + prune_threshold, + } = input; + + let n = detectors.len(); + let n_obs = observables.len(); + let has_stochastic = true; // conservative; could check noise params + + // Build noise map once, shared across all walks + let gate_index = crate::expand::GateIndex::build(gates, num_qubits); + let noise_map = if has_stochastic { + Some(crate::heisenberg::build_noise_map( + gates, + noise, + &gate_index.expansion_gates, + )) + } else { + None + }; + + // Helper: run one Heisenberg walk with a given stabilizer. + // Uses sparse traversal (heap + gate index) with optional noise map. + let walk = |stab: &Bm| -> f64 { + crate::heisenberg::heisenberg_sparse( + gates, + stab, + noise, + initial_stab, + prune_threshold, + &gate_index, + noise_map.as_deref(), + ) + }; + + let mut rates = BTreeMap::new(); + + // Cache walk results for product stabilizers: key = sorted det indices + let mut walk_cache: BTreeMap, f64> = BTreeMap::new(); + + // Collect all (indices, product_stabilizer) pairs for parallel walks + let mut walk_items: Vec<(Vec, Bm)> = Vec::new(); + for order in 1..=max_order.min(n) { + for_each_combination_idx(n, order, |indices| { + let mut product = detectors[indices[0]].stabilizer.clone(); + for &idx in &indices[1..] { + product = product.multiply(&detectors[idx].stabilizer); + } + let det_ids: Vec = indices.iter().map(|&i| detectors[i].id).collect(); + walk_items.push((det_ids, product)); + }); + } + + // Run all walks in parallel + let walk_results: Vec<(Vec, f64)> = walk_items + .par_iter() + .map(|(det_ids, product)| { + let p_walk = walk(product); + (det_ids.clone(), p_walk) + }) + .collect(); + + let num_walks = walk_results.len(); + for (det_ids, p_walk) in walk_results { + walk_cache.insert(det_ids, p_walk); + } + + // Convert walk results to joint detection probabilities via inclusion-exclusion. + // P(D_{i1}, ..., D_{ik}) = 1/2^k * sum_{T ⊆ {i1..ik}} (-1)^{k-|T|} * + // where = 1 - 2 * walk_result for that product. + for order in 1..=max_order.min(n) { + for_each_combination_idx(n, order, |indices| { + let det_ids: Vec = indices.iter().map(|&i| detectors[i].id).collect(); + let k = order; + + let mut prob = 0.0_f64; + let inv_2k = 1.0 / (1u64 << k) as f64; + + // Iterate over all subsets T of {0..k-1} + for mask in 0..(1u64 << k) { + let subset_size = mask.count_ones() as usize; + let sign = if subset_size.is_multiple_of(2) { + 1.0 + } else { + -1.0 + }; + + if subset_size == 0 { + // Empty subset: = 1, contribution = (-1)^k * 1 + prob += sign; + } else { + // Build the subset's detector IDs + let subset_det_ids: Vec = (0..k) + .filter(|&bit| mask & (1u64 << bit) != 0) + .map(|bit| detectors[indices[bit]].id) + .collect(); + + // Look up the walk result for this subset + if let Some(&p_walk) = walk_cache.get(&subset_det_ids) { + // = 1 - 2 * p_walk + let expectation = 1.0 - 2.0 * p_walk; + prob += sign * expectation; + } + } + } + + prob *= inv_2k; + if prob.abs() > 1e-15 { + rates.insert(det_ids, prob.max(0.0)); + } + }); + } + + // Compute detector-observable cross-correlations. + // For each observable L_j and each detector subset {D_i1,...,D_ik}: + // P(D_i1,...,D_ik, L_j) via inclusion-exclusion with the observable + // stabilizer included in the product. + // + // For single detector + observable: + // P(Di, Lj) = 1/4 (1 - - + ) + // + // We compute P(Lj) and P(Di, Lj) for each detector-observable pair. + let mut observable_rates: BTreeMap<(Vec, usize), f64> = BTreeMap::new(); + + // Collect observable walk items for parallel execution + // Items: (obs_id, Option, product_stabilizer) + let mut obs_walk_items: Vec<(usize, Option, Bm)> = Vec::new(); + for obs in observables { + obs_walk_items.push((obs.id, None, obs.pauli.clone())); + for det in detectors { + let product = det.stabilizer.multiply(&obs.pauli); + obs_walk_items.push((obs.id, Some(det.id), product)); + } + } + + let obs_walk_results: Vec<(usize, Option, f64)> = obs_walk_items + .par_iter() + .map(|(obs_id, det_id, product)| (*obs_id, *det_id, walk(product))) + .collect(); + + let num_walks = num_walks + obs_walk_results.len(); + + // Process observable walk results: marginals first, then pairwise + let mut obs_marginals: BTreeMap = BTreeMap::new(); + for &(obs_id, det_id, p_walk) in &obs_walk_results { + if det_id.is_none() { + obs_marginals.insert(obs_id, p_walk); + observable_rates.insert((vec![], obs_id), p_walk); + } + } + for &(obs_id, det_id, p_walk) in &obs_walk_results { + if let Some(d_id) = det_id { + let p_di = walk_cache.get(&vec![d_id]).copied().unwrap_or(0.0); + let p_obs = obs_marginals.get(&obs_id).copied().unwrap_or(0.0); + let p_joint = (p_di + p_obs - p_walk) / 2.0; + if p_joint.abs() > 1e-15 { + observable_rates.insert((vec![d_id], obs_id), p_joint.max(0.0)); + } + } + } + + CorrelationTable { + rates, + observable_rates, + max_order: max_order.min(n), + num_detectors: n, + num_observables: n_obs, + num_walks, + } +} + +/// Iterate over all k-combinations of indices 0..n, calling f with each sorted combination. +fn for_each_combination_idx(n: usize, k: usize, mut f: impl FnMut(&[usize])) { + if k == 0 || n < k { + return; + } + let mut combo = vec![0usize; k]; + combination_recurse_idx(n, k, 0, 0, &mut combo, &mut f); +} + +fn combination_recurse_idx( + n: usize, + k: usize, + start: usize, + depth: usize, + combo: &mut [usize], + f: &mut impl FnMut(&[usize]), +) { + if depth == k { + f(&combo[..k]); + return; + } + let remaining = k - depth; + if start + remaining > n { + return; + } + for i in start..=(n - remaining) { + combo[depth] = i; + combination_recurse_idx(n, k, i + 1, depth + 1, combo, f); + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_combination_idx() { + let mut results = Vec::new(); + for_each_combination_idx(4, 2, |combo| { + results.push(combo.to_vec()); + }); + assert_eq!(results.len(), 6); // C(4,2) = 6 + assert_eq!(results[0], vec![0, 1]); + assert_eq!(results[5], vec![2, 3]); + } +} diff --git a/exp/pecos-eeg/src/dem_generator.rs b/exp/pecos-eeg/src/dem_generator.rs new file mode 100644 index 000000000..f36dd7608 --- /dev/null +++ b/exp/pecos-eeg/src/dem_generator.rs @@ -0,0 +1,261 @@ +// 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. + +//! DEM generator trait and implementations. +//! +//! Unifies all DEM generation methods behind a single trait. Any generator +//! can be used as a simulator backend via the meas_sampling path. + +use crate::dem_mapping::{DecomposableDemEntry, DemEntry, Detector, Observable}; +use crate::expand::{ExpandedCircuit, GateIndex}; +use crate::noise::NoiseSpec; +use pecos_core::Gate; + +/// Noise parameters for DEM generation (uniform depolarizing + coherent idle). +#[derive(Clone, Debug)] +pub struct NoiseParams { + pub p1: f64, + pub p2: f64, + pub p_meas: f64, + pub p_prep: f64, + pub idle_rz: f64, +} + +/// Input context for DEM generation. +/// +/// Contains everything a generator needs: expanded circuit, detectors, +/// observables, and precomputed indices. +pub struct DemContext<'a> { + pub gates: &'a [Gate], + pub expanded: &'a ExpandedCircuit, + pub gate_index: &'a GateIndex, + pub detectors: &'a [Detector], + pub observables: &'a [Observable], +} + +/// Output from a DEM generator. +pub struct DemOutput { + /// Raw DEM entries (for tesseract and other hyperedge-capable decoders). + pub entries: Vec, + /// Decomposable entries with X/Z provenance (for MWPM decoders). + /// None if the generator doesn't support decomposition. + pub decomposable: Option>, +} + +/// Trait for DEM generators. +/// +/// Any type implementing this can generate a Detector Error Model from +/// a circuit and noise parameters. Implementations can then be used as +/// simulator backends via the meas_sampling path. +pub trait DemGenerator: Send + Sync { + /// Generate a DEM from the given context and noise. + fn generate(&self, ctx: &DemContext<'_>, noise: &dyn NoiseSpec) -> DemOutput; + + /// Human-readable name for this generator method. + fn name(&self) -> &str; +} + +/// Coherent DEM generator (backward Heisenberg mechanism extraction, approximate probabilities). +/// +/// Fast. Handles coherent noise (idle_rz). Approximate probabilities +/// (sin^2 for H-type, (1-exp(2s))/2 for S-type). +pub struct CoherentApprox; + +impl DemGenerator for CoherentApprox { + fn generate(&self, ctx: &DemContext<'_>, noise: &dyn NoiseSpec) -> DemOutput { + let entries = crate::coherent_dem::build_coherent_dem( + ctx.gates, + noise, + ctx.detectors, + ctx.observables, + &ctx.gate_index.expansion_gates, + ); + let decomposable = crate::coherent_dem::build_coherent_dem_decomposable( + ctx.gates, + noise, + ctx.detectors, + ctx.observables, + &ctx.gate_index.expansion_gates, + ); + DemOutput { + entries, + decomposable: Some(decomposable), + } + } + + fn name(&self) -> &'static str { + "coherent_approx" + } +} + +/// Coherent DEM generator with Heisenberg-exact probability fitting. +/// +/// Slower (runs Heisenberg walks for marginals + pairwise). Handles coherent +/// noise. Exact marginals via L-BFGS fit. +pub struct CoherentExact { + pub prune_threshold: f64, +} + +impl Default for CoherentExact { + fn default() -> Self { + Self { + prune_threshold: 1e-12, + } + } +} + +impl DemGenerator for CoherentExact { + fn generate(&self, ctx: &DemContext<'_>, noise: &dyn NoiseSpec) -> DemOutput { + use crate::heisenberg::{build_noise_map, heisenberg_sparse}; + use crate::stabilizer::StabilizerGroup; + + // Build initial stabilizer group + let init_gates: Vec = (0..ctx.expanded.num_original_qubits) + .map(|q| crate::expand::make_gate(pecos_core::gate_type::GateType::PZ, &[q])) + .collect(); + let stab = StabilizerGroup::from_circuit(&init_gates, ctx.expanded.num_qubits); + + // Heisenberg walks for exact marginals + let noise_map = build_noise_map(ctx.gates, noise, &ctx.gate_index.expansion_gates); + + let num_dets = ctx.detectors.iter().map(|d| d.id + 1).max().unwrap_or(0); + let mut marginals = vec![0.0_f64; num_dets]; + for det in ctx.detectors { + let p = heisenberg_sparse( + ctx.gates, + &det.stabilizer, + noise, + &stab, + self.prune_threshold, + ctx.gate_index, + Some(&noise_map), + ); + if det.id < marginals.len() { + marginals[det.id] = p; + } + } + + // Pairwise rates + let mut pairwise: Vec<((usize, usize), f64)> = Vec::new(); + for i in 0..ctx.detectors.len() { + for j in (i + 1)..ctx.detectors.len() { + let product = ctx.detectors[i] + .stabilizer + .multiply(&ctx.detectors[j].stabilizer); + let p_product = heisenberg_sparse( + ctx.gates, + &product, + noise, + &stab, + self.prune_threshold, + ctx.gate_index, + Some(&noise_map), + ); + let p_joint = (marginals[ctx.detectors[i].id] + marginals[ctx.detectors[j].id] + - p_product) + / 2.0; + if p_joint > 1e-10 { + pairwise.push(((ctx.detectors[i].id, ctx.detectors[j].id), p_joint.max(0.0))); + } + } + } + + // Exact-fitted entries + let entries = crate::coherent_dem::build_coherent_dem_exact( + ctx.gates, + noise, + ctx.detectors, + ctx.observables, + &ctx.gate_index.expansion_gates, + &marginals, + Some(&pairwise), + ); + let decomposable = crate::coherent_dem::build_coherent_dem_exact_decomposable( + ctx.gates, + noise, + ctx.detectors, + ctx.observables, + &ctx.gate_index.expansion_gates, + &marginals, + Some(&pairwise), + ); + + DemOutput { + entries, + decomposable: Some(decomposable), + } + } + + fn name(&self) -> &'static str { + "coherent_exact" + } +} + +/// Perturbative (forward EEG) DEM generator. +/// +/// Fastest. Uses forward EEG propagation with Taylor approximation. +/// Approximate probabilities (~50% error for coherent noise). +pub struct Perturbative; + +impl DemGenerator for Perturbative { + fn generate(&self, ctx: &DemContext<'_>, noise: &dyn NoiseSpec) -> DemOutput { + // Scaffolding imports for future forward-EEG implementation + #[allow(unused_imports)] + use crate::circuit::analyze_expanded; + #[allow(unused_imports)] + use crate::dem_mapping::{EegConfig, build_dem_configured, build_dem_decomposable}; + #[allow(unused_imports)] + use crate::noise::UniformNoise; + + // We need to extract params from the NoiseSpec — use a test gate to probe + let _ = noise.noise_after_gate(0, pecos_core::gate_type::GateType::H, &[0]); + + // For now, use the coherent_dem path as fallback since forward EEG + // requires its own NoiseModel type (not the NoiseSpec trait) + let entries = crate::coherent_dem::build_coherent_dem( + ctx.gates, + noise, + ctx.detectors, + ctx.observables, + &ctx.gate_index.expansion_gates, + ); + + // Forward EEG would need stabilizer group for proper classification + // Use coherent_dem decomposable for now + let decomposable = crate::coherent_dem::build_coherent_dem_decomposable( + ctx.gates, + noise, + ctx.detectors, + ctx.observables, + &ctx.gate_index.expansion_gates, + ); + + DemOutput { + entries, + decomposable: Some(decomposable), + } + } + + fn name(&self) -> &'static str { + "perturbative" + } +} + +/// Select a DEM generator by method name. +#[must_use] +pub fn select_generator(method: &str, _idle_rz: f64) -> Box { + match method { + "coherent_exact" => Box::new(CoherentExact::default()), + "perturbative" => Box::new(Perturbative), + _ => Box::new(CoherentApprox), // auto/coherent/default fallback + } +} diff --git a/exp/pecos-eeg/src/dem_mapping.rs b/exp/pecos-eeg/src/dem_mapping.rs new file mode 100644 index 000000000..8eca6bdb9 --- /dev/null +++ b/exp/pecos-eeg/src/dem_mapping.rs @@ -0,0 +1,1784 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! DEM event classification and probability computation. +//! +//! Classifies propagated EEG generators by DEM event class (which +//! detectors each Pauli anticommutes with), then computes event +//! probabilities using the correct formulas from Hines et al. + +use crate::Bm; +use crate::circuit::PropagatedEeg; +use crate::eeg::EegType; +use crate::stabilizer::StabilizerGroup; +use pecos_core::pauli::pauli_bitmask::BitmaskStorage; +use pecos_core::{Pauli, PauliString}; +use smallvec::SmallVec; +use std::collections::BTreeMap; +use std::fmt::Write as _; + +type DetectorSet = SmallVec<[usize; 4]>; +type ObservableSet = SmallVec<[usize; 2]>; +type EventKey = (DetectorSet, ObservableSet); +type XzComponents = (Option, Option); +type GraphlikePieces = Vec; +type DecompMemo = BTreeMap>; + +/// Controls the H-type probability formula. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum HFormula { + /// p = sum h_j h_k beta (leading-order Taylor). Fast, accurate for small angles. + #[default] + Taylor, + /// p = sin^2(h_eff) applied to the Taylor quadratic form. + SinSquared, + /// Exact product formula for commuting generators: + /// p = (1/2)(1 - Re(prod_j factor_j)) + /// where factor_j depends on whether P_j or D·P_j is a stabilizer. + /// Captures all orders for the commuting case. + ExactCommuting, + /// Exact subset sum formula for commuting generators: + /// p = (1/2)(1 - Re(Σ_S i^|S| Π sin · Π cos · ε_S)) + /// Enumerates all even-size subsets of generators, checks if the + /// product (Π_{j∈S} P_j)·D is a stabilizer. Captures all orders of + /// multi-body interference. Exact for commuting generators. Cost: O(2^N) + /// where N is generators per event. Practical for N ≤ ~25. + ExactSubset, +} + +/// BCH order for generator accumulation. +#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)] +pub enum BchOrder { + /// First-order: G_c = sum G_i. Same-label rates add. + #[default] + First, + /// Second-order: G_c = sum G_i + (1/2) sum [G_i, G_j]. + /// Adds new H generators from [H_P, H_Q] = 2i H_{PQ} for anticommuting P,Q. + /// Also adds Zassenhaus W_2 cross-event [H,S] → C corrections. + Second, +} + +/// Configuration for the EEG DEM builder. +/// +/// Controls all three approximation levels described in the paper: +/// 1. BCH order (k): how generators from different layers are combined +/// 2. Zassenhaus order: how the combined generator is split into single-event channels +/// (coupled to BCH order — Second enables W_2 cross-event terms) +/// 3. H-type formula: how detection probabilities are estimated from generators +#[derive(Clone, Copy, Debug)] +pub struct EegConfig { + /// BCH expansion order for combining layer errors (default: First). + pub bch_order: BchOrder, + /// Formula for H-type detection probability (default: Taylor). + pub h_formula: HFormula, +} + +impl Default for EegConfig { + fn default() -> Self { + Self { + bch_order: BchOrder::First, + h_formula: HFormula::Taylor, + } + } +} + +impl EegConfig { + /// Create config with default settings (first-order BCH, Taylor formula). + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Set BCH order to first (default). Same-label rate summation only. + #[must_use] + pub fn bch_first(mut self) -> Self { + self.bch_order = BchOrder::First; + self + } + + /// Set BCH order to second. Adds [H,H] and [H,S] commutator corrections. + #[must_use] + pub fn bch_second(mut self) -> Self { + self.bch_order = BchOrder::Second; + self + } + + /// Use leading-order Taylor (h²) for H-type probabilities (default). + #[must_use] + pub fn taylor(mut self) -> Self { + self.h_formula = HFormula::Taylor; + self + } + + /// Use sin²(h_eff) for H-type probabilities. + #[must_use] + pub fn sin_squared(mut self) -> Self { + self.h_formula = HFormula::SinSquared; + self + } + + /// Use exact product formula for commuting H-type generators. + #[must_use] + pub fn exact_commuting(mut self) -> Self { + self.h_formula = HFormula::ExactCommuting; + self + } + + /// Use exact subset-sum formula for commuting H-type generators. + /// Captures all orders of multi-body interference. O(2^N) per event. + #[must_use] + pub fn exact_subset(mut self) -> Self { + self.h_formula = HFormula::ExactSubset; + self + } +} + +/// A detector definition for EEG classification. +#[derive(Clone, Debug)] +pub struct Detector { + pub id: usize, + /// Pauli stabilizer. A Pauli P flips this detector iff P anticommutes with it. + pub stabilizer: Bm, +} + +/// A logical observable for EEG classification. +#[derive(Clone, Debug)] +pub struct Observable { + pub id: usize, + pub pauli: Bm, +} + +/// A DEM event: the set of detectors and observables flipped. +#[derive(Clone, Debug, PartialEq, Eq, PartialOrd, Ord, Hash)] +pub struct DemEvent { + pub detectors: SmallVec<[usize; 4]>, + pub observables: SmallVec<[usize; 2]>, +} + +/// A DEM entry: event + probability. +#[derive(Clone, Debug)] +pub struct DemEntry { + pub event: DemEvent, + pub probability: f64, +} + +/// A DEM entry with X/Z decomposition info for MWPM decoders. +/// +/// When both `x_component` and `z_component` are `Some`, the mechanism +/// can be output in decomposed form: `error(p) x_targets ^ z_targets`. +/// When only one is present (pure X or Z error), output directly. +#[derive(Clone, Debug)] +pub struct DecomposableDemEntry { + /// Combined detector/observable flips (XOR of X and Z components). + pub event: DemEvent, + /// Mechanism probability. + pub probability: f64, + /// Detector/observable flips from the X-only component of the Pauli label. + /// None if the label has no X part. + pub x_component: Option, + /// Detector/observable flips from the Z-only component of the Pauli label. + /// None if the label has no Z part. + pub z_component: Option, +} + +/// Convert PauliString to Bm. +#[must_use] +pub fn pauli_string_to_bitmask(ps: &PauliString) -> Bm { + let mut bm = Bm::default(); + for &(pauli, qubit) in ps.paulis() { + let q = qubit.index(); + match pauli { + Pauli::X => bm.x_bits.set_bit(q), + Pauli::Z => bm.z_bits.set_bit(q), + Pauli::Y => { + bm.x_bits.set_bit(q); + bm.z_bits.set_bit(q); + } + Pauli::I => {} + } + } + bm +} + +/// Classify which detectors and observables a Pauli label anticommutes with. +fn classify(label: &Bm, detectors: &[Detector], observables: &[Observable]) -> DemEvent { + let mut dets = SmallVec::new(); + for det in detectors { + if !label.commutes_with(&det.stabilizer) { + dets.push(det.id); + } + } + let mut obs = SmallVec::new(); + for o in observables { + if !label.commutes_with(&o.pauli) { + obs.push(o.id); + } + } + DemEvent { + detectors: dets, + observables: obs, + } +} + +/// Classify with X/Z component decomposition. +/// +/// Returns (combined_event, x_component, z_component) where x_component +/// is the detectors/observables flipped by the X-only part of the label, +/// and z_component by the Z-only part. +fn classify_xz( + label: &Bm, + detectors: &[Detector], + observables: &[Observable], +) -> (DemEvent, Option, Option) { + use pecos_core::pauli::pauli_bitmask::BitmaskStorage; + + let has_x = !label.x_bits.is_zero(); + let has_z = !label.z_bits.is_zero(); + + // Build X-only and Z-only labels + let x_only = if has_x { + Some(Bm { + x_bits: label.x_bits.clone(), + ..Default::default() + }) + } else { + None + }; + let z_only = if has_z { + Some(Bm { + z_bits: label.z_bits.clone(), + ..Default::default() + }) + } else { + None + }; + + let combined = classify(label, detectors, observables); + + let x_event = x_only.map(|x_label| classify(&x_label, detectors, observables)); + let z_event = z_only.map(|z_label| classify(&z_label, detectors, observables)); + + (combined, x_event, z_event) +} + +/// Build a DEM from propagated EEG generators. +/// +/// Groups generators by DEM event class, then computes probabilities: +/// - **S-only event**: p = (1/2)(1 - exp(-2 * sum_rates)) [exact] +/// - **H-only event**: p = sum_j h_j^2 [leading order, diagonal terms] +/// (Off-diagonal coherent interference captured at second order via beta) +/// - **Mixed**: S at O(epsilon) + H^2 at O(epsilon^2) +/// +/// For first-order BCH with leading-order Taylor, the H-only formula uses +/// only diagonal terms: p = sum_j h_j^2. This is the Pauli-twirled +/// equivalent. To capture coherent accumulation (off-diagonal terms), +/// we need to check if pairs of H generators anticommute with the same +/// detectors AND their product Q_j*Q_k is a stabilizer of |psi>. +/// +/// For the first implementation, we use the diagonal approximation for H +/// and the exact formula for S. This gives correct results for stochastic +/// noise and a Pauli-twirled approximation for coherent noise. The full +/// coherent formula (with off-diagonal beta terms) is future work. +#[must_use] +pub fn build_dem( + generators: &[PropagatedEeg], + detectors: &[Detector], + observables: &[Observable], +) -> Vec { + build_dem_with_stabilizers(generators, detectors, observables, None) +} + +/// Build DEM with stabilizer group for coherent interference. +#[must_use] +pub fn build_dem_with_stabilizers( + generators: &[PropagatedEeg], + detectors: &[Detector], + observables: &[Observable], + stabilizer_group: Option<&StabilizerGroup>, +) -> Vec { + build_dem_inner( + generators, + detectors, + observables, + stabilizer_group, + HFormula::Taylor, + BchOrder::First, + ) +} + +/// Build DEM with all options via config struct. +#[must_use] +pub fn build_dem_configured( + generators: &[PropagatedEeg], + detectors: &[Detector], + observables: &[Observable], + stabilizer_group: Option<&StabilizerGroup>, + config: &EegConfig, +) -> Vec { + build_dem_inner( + generators, + detectors, + observables, + stabilizer_group, + config.h_formula, + config.bch_order, + ) +} + +/// Build DEM with individual options (convenience). +#[must_use] +pub fn build_dem_with_options( + generators: &[PropagatedEeg], + detectors: &[Detector], + observables: &[Observable], + stabilizer_group: Option<&StabilizerGroup>, + h_formula: HFormula, + bch_order: BchOrder, +) -> Vec { + build_dem_inner( + generators, + detectors, + observables, + stabilizer_group, + h_formula, + bch_order, + ) +} + +fn build_dem_inner( + generators: &[PropagatedEeg], + detectors: &[Detector], + observables: &[Observable], + stabilizer_group: Option<&StabilizerGroup>, + h_formula: HFormula, + bch_order: BchOrder, +) -> Vec { + // First-order BCH: combine generators with the same Pauli label. + // This is where coherent accumulation happens: h1 + h2 for same label. + let mut h_by_label: BTreeMap = BTreeMap::new(); + let mut s_by_label: BTreeMap = BTreeMap::new(); + + // C and A type generators: two labels, first-order contribution. + // Key = (label1, label2), value = coefficient. + let mut c_generators: Vec<(Bm, Bm, f64)> = Vec::new(); + let mut a_generators: Vec<(Bm, Bm, f64)> = Vec::new(); + + for g in generators { + match g.eeg_type { + EegType::H => { + *h_by_label.entry(g.label.clone()).or_insert(0.0) += g.coeff; + } + EegType::S => { + *s_by_label.entry(g.label.clone()).or_insert(0.0) += g.coeff; + } + EegType::C => { + if let Some(l2) = g.label2.clone() { + c_generators.push((g.label.clone(), l2, g.coeff)); + } + } + EegType::A => { + if let Some(l2) = g.label2.clone() { + a_generators.push((g.label.clone(), l2, g.coeff)); + } + } + } + } + + // Second-order BCH: [H_P, H_Q] = -2i H_{PQ} for anticommuting P, Q. + // PQ = i^k * R (from multiply_with_phase), so H_{PQ} = i^k * H_R. + // BCH coefficient: (1/2) * (-2i) * i^k * h_i * h_j = -i^{k+1} * h_i * h_j. + // This can be real or imaginary depending on k. + let mut h_bch2_re_by_label: BTreeMap = BTreeMap::new(); + let mut h_imag_by_label: BTreeMap = BTreeMap::new(); + + if bch_order == BchOrder::Second { + let h_entries: Vec<(Bm, f64)> = h_by_label.iter().map(|(l, &c)| (l.clone(), c)).collect(); + + for i in 0..h_entries.len() { + for j in (i + 1)..h_entries.len() { + let (p_i, h_i) = &h_entries[i]; + let (p_j, h_j) = &h_entries[j]; + + if p_i.commutes_with(p_j) { + continue; + } + + // PQ = i^k * R. Coefficient: -i^{k+1} * h_i * h_j = i^{k+3} * h_i * h_j. + let (product, phase_k) = p_i.multiply_with_phase(p_j); + let mag = h_i * h_j; + let phase = (phase_k + 3) % 4; // -i^{k+1} = i^{k+3} + let (re_coeff, im_coeff) = match phase { + 0 => (mag, 0.0), // 1 + 1 => (0.0, mag), // i + 2 => (-mag, 0.0), // -1 + 3 => (0.0, -mag), // -i + _ => unreachable!(), + }; + + *h_bch2_re_by_label.entry(product.clone()).or_insert(0.0) += re_coeff; + *h_imag_by_label.entry(product).or_insert(0.0) += im_coeff; + } + } + } + + // Zassenhaus W_2: cross-event commutators produce generators in new or + // existing event classes. These are iteratively decomposed by event class + // and their detection contributions computed (paper step 4). + // + // [H_P, S_Q] = i C_{Q, [Q,P]} → C-type with imaginary coeff i·h·s + // [H_P, H_Q] = -i H_{[P,Q]} → H-type with imaginary coeff (already in BCH2 above) + // [S_P, S_Q] = 0 → no contribution + // + // The [H,S] cross-terms produce C-type generators. At leading order, + // their purely imaginary coefficients give zero contribution to + // detection: Re(i·h·s · β) = 0 for real β. The paper's O(ε^{3/2}) + // error bound accounts for this. + if bch_order == BchOrder::Second { + let h_entries: Vec<(Bm, f64)> = h_by_label.iter().map(|(l, &c)| (l.clone(), c)).collect(); + let s_entries: Vec<(Bm, f64)> = s_by_label.iter().map(|(l, &c)| (l.clone(), c)).collect(); + + for (p, _h_coeff) in &h_entries { + for (q, _s_coeff) in &s_entries { + if p.commutes_with(q) { + continue; + } + + // [H_P, S_Q] = i C_{Q, QP} (for anticommuting P,Q: [Q,P]=2QP) + // QP = i^k * R (from multiply_with_phase). + // Zassenhaus (1/2) factor: coeff = (1/2)·h·s·i·2·i^k = i^{k+1}·h·s + // For k=0: purely imaginary → zero real contribution at leading order. + // For k≠0: may have real part, but still O(ε^{3/2}). + let (qp, _phase) = q.multiply_with_phase(p); + c_generators.push((q.clone(), qp, 0.0)); + } + } + } + + // Merge real and imaginary H-type generators into complex coefficients. + // BCH2 can contribute both real and imaginary parts to generator labels. + // Merge BCH2 real parts into h_by_label. + for (label, &re) in &h_bch2_re_by_label { + *h_by_label.entry(label.clone()).or_insert(0.0) += re; + } + + let all_h_labels: std::collections::BTreeSet = h_by_label + .keys() + .chain(h_imag_by_label.keys()) + .cloned() + .collect(); + + // Group BCH-combined generators by DEM event class. + // Store (real, imag) coefficient pairs per label. + let mut h_events: BTreeMap> = BTreeMap::new(); + let mut s_events: BTreeMap> = BTreeMap::new(); + let mut event_pauli_labels: BTreeMap> = BTreeMap::new(); + + for label in &all_h_labels { + let re = h_by_label.get(label).copied().unwrap_or(0.0); + let im = h_imag_by_label.get(label).copied().unwrap_or(0.0); + if re.abs() < 1e-20 && im.abs() < 1e-20 { + continue; + } + let event = classify(label, detectors, observables); + if event.detectors.is_empty() && event.observables.is_empty() { + continue; + } + h_events.entry(event.clone()).or_default().push((re, im)); + event_pauli_labels + .entry(event) + .or_default() + .push(label.clone()); + } + + for (label, &coeff) in &s_by_label { + let event = classify(label, detectors, observables); + if event.detectors.is_empty() && event.observables.is_empty() { + continue; + } + s_events.entry(event).or_default().push(coeff); + } + + let mut entries = Vec::new(); + + // S-only events: exact formula + // p_D = (1/2)(1 - exp(2 * sum_rates)) + // Note: S rates are negative (e.g., -p/3), so 2*sum is negative, + // exp(2*sum) < 1, and p_D > 0. + for (event, rates) in &s_events { + let sum_rate: f64 = rates.iter().sum(); + let prob = (1.0 - (2.0 * sum_rate).exp()) / 2.0; + if prob.abs() > 1e-15 { + entries.push(DemEntry { + event: event.clone(), + probability: prob.abs(), + }); + } + } + + // C-type and A-type first-order contributions. + // β(ψ, C_{Q1,Q2}, P) = ±4 if [Q1,Q2]=0, [Q1,P]≠0, [Q2,P]≠0, Q1Q2|ψ⟩=∓|ψ⟩ + // β(ψ, A_{Q1,Q2}, P) = ±4 if [Q1,Q2]≠0, [Q1,P]≠0, [Q2,P]≠0, iQ1Q2|ψ⟩=±|ψ⟩ + // These contribute at first order (same as S). + if let Some(stab_group) = stabilizer_group { + for &(ref q1, ref q2, coeff) in c_generators.iter().chain(a_generators.iter()) { + // Classify: both Q1 and Q2 must anticommute with the same detectors + let event1 = classify(q1, detectors, observables); + let event2 = classify(q2, detectors, observables); + if event1 != event2 || (event1.detectors.is_empty() && event1.observables.is_empty()) { + continue; + } + let event = event1; + + // Check commutativity condition + let q1_q2_commute = q1.commutes_with(q2); + let is_c_type = c_generators.iter().any(|(a, b, _)| a == q1 && b == q2); + + // C requires [Q1,Q2]=0, A requires [Q1,Q2]≠0 + if is_c_type && !q1_q2_commute { + continue; + } + if !is_c_type && q1_q2_commute { + continue; + } + + // Check product stabilizer status + let product = q1.multiply(q2); + let beta = if product.is_identity() { + Some(true) + } else { + stab_group.is_stabilizer(&product) + }; + + // β = ±4, contribution to p_D = coeff * β / (-2) at first order + // (detection probability p = (1/2)(1 - ⟨Q⟩), ⟨Q⟩ ≈ 1 + β·ε) + // For C: β = ±4 → p contribution = -coeff * (±4) / 2 = ∓2·coeff + // For A: β = ±4 → same + if let Some(sign) = beta { + let beta_val = if sign { -4.0 } else { 4.0 }; + // For A-type, the stabilizer check is on iQ1Q2, not Q1Q2. + // Since we check Q1Q2, the sign interpretation differs for A. + // A: iQ1Q2|ψ⟩=±|ψ⟩ → Q1Q2|ψ⟩=∓i|ψ⟩. For real stabilizer + // eigenvalues (±1), this means Q1Q2 is NOT a real stabilizer. + // A-type only contributes when iQ1Q2 has eigenvalue ±1, + // which means Q1Q2 has eigenvalue ∓i. Skip for now since + // stabilizer eigenvalues are always ±1. + if !is_c_type { + continue; + } + + let prob_contribution = -coeff * beta_val / 2.0; + if prob_contribution.abs() > 1e-15 { + if let Some(existing) = entries.iter_mut().find(|e| e.event == event) { + let p_s = existing.probability; + existing.probability = + p_s + prob_contribution.abs() - 2.0 * p_s * prob_contribution.abs(); + } else { + entries.push(DemEntry { + event: event.clone(), + probability: prob_contribution.abs(), + }); + } + } + } + } + } + + // H-only events: full leading-order formula with coherent interference. + // + // p_D = -(1/4) * sum_{j,k} h_j * h_k * beta(psi, C_{Qj,Qk}, P) + // + // beta(psi, C_{Q,Q'}, P) = -4 if [Q,Q']=0 and Q*Q'|psi>=+|psi> (stabilizer) + // = +4 if [Q,Q']=0 and Q*Q'|psi>=-|psi> (anti-stabilizer) + // = 0 otherwise + // + // Diagonal terms (j=k): Q*Q=I, always +1 stabilizer → beta=-4 → contributes +h_j^2 + // Off-diagonal with stabilizer product: contributes +h_j*h_k (constructive) + // Off-diagonal with anti-stabilizer: contributes -h_j*h_k (destructive) + // + // If stabilizer_group is None, fall back to diagonal approximation. + for (event, coeffs) in &h_events { + // Collect the detector stabilizers for this event (for ExactCommuting) + let event_det_stab = + if h_formula == HFormula::ExactCommuting || h_formula == HFormula::ExactSubset { + // XOR of all detector stabilizers in this event + let mut stab = Bm::default(); + for &d_id in &event.detectors { + if let Some(det) = detectors.iter().find(|d| d.id == d_id) { + stab = stab.multiply(&det.stabilizer); + } + } + Some(stab) + } else { + None + }; + + let prob = if let Some(stab_group) = stabilizer_group { + compute_h_probability_full( + coeffs, + generators, + &event_pauli_labels, + event, + stab_group, + h_formula, + event_det_stab.as_ref(), + ) + } else { + coeffs.iter().map(|&(re, im)| re * re + im * im).sum() + }; + if prob > 1e-15 { + if let Some(existing) = entries.iter_mut().find(|e| e.event == *event) { + let p_s = existing.probability; + existing.probability = p_s + prob - 2.0 * p_s * prob; + } else { + entries.push(DemEntry { + event: event.clone(), + probability: prob, + }); + } + } + } + + entries +} + +/// Compute H-type event probability using the full beta function. +/// +/// p_D = -(1/4) * sum_{j,k} h_j * h_k * beta(psi, C_{Qj,Qk}, P) +/// +/// For each pair (j,k): +/// - If [Q_j, Q_k] ≠ 0: beta = 0 +/// - If [Q_j, Q_k] = 0 and Q_j*Q_k is +1 stabilizer: beta = -4 → +h_j*h_k +/// - If [Q_j, Q_k] = 0 and Q_j*Q_k is -1 stabilizer: beta = +4 → -h_j*h_k +/// - Otherwise: beta = 0 +fn compute_h_probability_full( + coeffs: &[(f64, f64)], + _generators: &[PropagatedEeg], + event_labels: &BTreeMap>, + event: &DemEvent, + stab_group: &StabilizerGroup, + h_formula: HFormula, + det_stabilizer: Option<&Bm>, +) -> f64 { + let Some(labels) = event_labels.get(event) else { + return 0.0; + }; + + let n = coeffs.len(); + + // --- ExactCommuting: product formula for commuting generators --- + if h_formula == HFormula::ExactCommuting + && let Some(det_stab) = det_stabilizer + { + return compute_exact_commuting(coeffs, labels, stab_group, det_stab); + } + + // --- ExactSubset: enumerate all even-size subsets --- + if h_formula == HFormula::ExactSubset + && let Some(det_stab) = det_stabilizer + { + return compute_exact_subset(coeffs, labels, stab_group, det_stab); + } + + // --- Taylor or SinSquared: quadratic form with beta --- + let mut total = 0.0_f64; + + for j in 0..n { + for k in 0..n { + let (re_j, im_j) = coeffs[j]; + let (re_k, im_k) = coeffs[k]; + let re_product = re_j * re_k - im_j * im_k; + + if j == k { + total += re_j * re_j + im_j * im_j; + } else { + let q_j = &labels[j]; + let q_k = &labels[k]; + + if !q_j.commutes_with(q_k) { + continue; + } + + let product = q_j.multiply(q_k); + + if product.is_identity() { + total += re_product; + continue; + } + + match stab_group.is_stabilizer(&product) { + Some(true) => { + total += re_product; + } + Some(false) => { + total -= re_product; + } + None => {} + } + } + } + } + + let total = total.max(0.0); + + match h_formula { + HFormula::SinSquared => { + let h_eff = total.sqrt(); + h_eff.sin().powi(2) + } + HFormula::Taylor | HFormula::ExactCommuting | HFormula::ExactSubset => total, + } +} + +/// Exact product formula for commuting H-type generators. +/// +/// For each generator P_j with rate h_j: +/// - If P_j is a ±1 stabilizer: factor = exp(±2i h_j) → contributes to phase +/// - If D·P_j is a ±1 stabilizer: factor = exp(∓2i h_j) → contributes to phase +/// - Neither: factor = cos(2h_j) → real damping +/// +/// p_D = (1/2)(1 - Re(prod_j factor_j)) +fn compute_exact_commuting( + coeffs: &[(f64, f64)], + labels: &[Bm], + stab_group: &StabilizerGroup, + det_stab: &Bm, +) -> f64 { + let n = coeffs.len(); + // Accumulate product as (real, imag) complex number + let mut prod_re = 1.0_f64; + let mut prod_im = 0.0_f64; + + for j in 0..n { + let (h_re, h_im) = coeffs[j]; + // For simplicity, use magnitude of complex coefficient + let h = (h_re * h_re + h_im * h_im).sqrt(); + if h < 1e-20 { + continue; + } + + let label = &labels[j]; + + // Check if P_j is a stabilizer (directly in expanded frame) + let p_stab = if label.is_identity() { + Some(true) + } else { + stab_group.is_stabilizer(label) + }; + + let (factor_re, factor_im) = if let Some(sign) = p_stab { + let s = if sign { 1.0 } else { -1.0 }; + let angle = 2.0 * s * h; + (angle.cos(), angle.sin()) + } else { + // Check D·P_j + let dp = det_stab.multiply(label); + let dp_stab = if dp.is_identity() { + Some(true) + } else { + stab_group.is_stabilizer(&dp) + }; + + if let Some(sign) = dp_stab { + let s = if sign { 1.0 } else { -1.0 }; + let angle = -2.0 * s * h; + (angle.cos(), angle.sin()) + } else { + ((2.0 * h).cos(), 0.0) + } + }; + + // Complex multiply: prod *= factor + let new_re = prod_re * factor_re - prod_im * factor_im; + let new_im = prod_re * factor_im + prod_im * factor_re; + prod_re = new_re; + prod_im = new_im; + } + + // p_D = (1/2)(1 - Re(product)) + let prob = 0.5 * (1.0 - prod_re); + prob.max(0.0) +} + +/// Exact subset-sum formula for commuting H-type generators. +/// +/// Enumerates ALL even-size subsets S of generators. For each: +/// coefficient = i^|S| · Π_{j∈S} sin(2h_j) · Π_{j∉S} cos(2h_j) +/// eigenvalue = ε_S = ⟨ψ|(Π_{j∈S} P_j)·D|ψ⟩ +/// +/// p_D = (1/2)(1 - Re(Σ_S coefficient · ε_S)) +/// +/// Only even-|S| subsets contribute to Re (odd powers of i are imaginary). +/// This captures all orders of multi-body interference. Exact when all +/// generators commute. Cost: O(2^N) where N = number of generators. +fn compute_exact_subset( + coeffs: &[(f64, f64)], + labels: &[Bm], + stab_group: &StabilizerGroup, + det_stab: &Bm, +) -> f64 { + let n = coeffs.len(); + + // Guard: too many generators → fall back to ExactCommuting + if n > 25 { + return compute_exact_commuting(coeffs, labels, stab_group, det_stab); + } + + // Precompute sin(2h_j) and cos(2h_j) for each generator + let mut sin2h = Vec::with_capacity(n); + let mut cos2h = Vec::with_capacity(n); + for &(h_re, h_im) in coeffs.iter().take(n) { + let h = (h_re * h_re + h_im * h_im).sqrt(); + sin2h.push((2.0 * h).sin()); + cos2h.push((2.0 * h).cos()); + } + + // The empty set (S = {}) contributes: Π cos(2h_j) · ε_{D} + // D itself should be a stabilizer with eigenvalue +1 (no error → ⟨D⟩=1). + let all_cos: f64 = cos2h.iter().product(); + + let mut sum_re = all_cos; // |S|=0 contribution + + // Enumerate all non-empty even-size subsets via bitmask + // For |S| even: i^|S| has Re = (-1)^{|S|/2} + let total_subsets = 1u64 << n; + for mask in 1..total_subsets { + let size = mask.count_ones() as usize; + if !size.is_multiple_of(2) { + continue; // odd-size subsets have Im(i^|S|) only → Re = 0 + } + + // Compute product of labels in S, multiplied by det_stab (D) + let mut product = det_stab.clone(); + for (j, label) in labels.iter().enumerate().take(n) { + if mask & (1u64 << j) != 0 { + product = product.multiply(label); + } + } + + // Check if this product is a stabilizer + let eigenvalue = if product.is_identity() { + Some(true) + } else { + stab_group.is_stabilizer(&product) + }; + + let epsilon = match eigenvalue { + Some(true) => 1.0, + Some(false) => -1.0, + None => continue, // not a stabilizer, ε=0 + }; + + // Coefficient: (-1)^{|S|/2} · Π_{j∈S} sin(2h_j) · Π_{j∉S} cos(2h_j) + let sign = if (size / 2).is_multiple_of(2) { + 1.0 + } else { + -1.0 + }; + + let mut coeff = sign; + for (j, (&sin, &cos)) in sin2h.iter().zip(&cos2h).enumerate().take(n) { + if mask & (1u64 << j) != 0 { + coeff *= sin; + } else { + coeff *= cos; + } + } + + sum_re += coeff * epsilon; + } + + let prob = 0.5 * (1.0 - sum_re); + prob.max(0.0) +} + +/// Sensitivity matrix M_E for a DEM event E (Hines Eq. 21). +/// +/// M_E encodes how physical-level Hamiltonian error parameters affect the +/// probability of event E: p_E = -(1/2) θ^T M_E θ. +/// +/// The matrix is indexed by `NoiseSource` pairs. Each entry M[i,j] = b_{P,Q_i,Q_j} +/// where b is the beta coefficient for the generator pair (Q_i, Q_j) and P is +/// the detector for event E. +/// +/// Returns: Vec of (source_i, source_j, value) triplets for non-zero entries. +#[must_use] +pub fn sensitivity_matrix( + generators: &[crate::circuit::PropagatedEeg], + detectors: &[Detector], + observables: &[Observable], + stabilizer_group: Option<&StabilizerGroup>, +) -> BTreeMap< + DemEvent, + Vec<( + crate::circuit::NoiseSource, + crate::circuit::NoiseSource, + f64, + )>, +> { + use crate::circuit::NoiseSource; + use crate::eeg::EegType; + + let mut result = BTreeMap::new(); + + // Collect H generators with their sources + let h_gens: Vec<_> = generators + .iter() + .filter(|g| g.eeg_type == EegType::H && g.source.is_some()) + .collect(); + + // Group by event class + let mut event_gens: BTreeMap> = BTreeMap::new(); + for g in &h_gens { + let event = classify(&g.label, detectors, observables); + if event.detectors.is_empty() && event.observables.is_empty() { + continue; + } + event_gens.entry(event).or_default().push(( + g.label.clone(), + g.coeff, + g.source.clone().unwrap(), + )); + } + + // For each event class, build the sensitivity matrix + for (event, gens) in &event_gens { + let mut entries = Vec::new(); + + for i in 0..gens.len() { + for j in 0..gens.len() { + let (ref label_i, _coeff_i, ref src_i) = gens[i]; + let (ref label_j, _coeff_j, ref src_j) = gens[j]; + + // Beta coefficient for pair (i,j) + let beta_val: f64 = if i == j { + 1.0 // diagonal: beta = -4, but -(1/4)*(-4) = 1 + } else if label_i.commutes_with(label_j) { + let product = label_i.multiply(label_j); + if product.is_identity() { + 1.0 + } else if let Some(stab) = stabilizer_group { + match stab.is_stabilizer(&product) { + Some(true) => 1.0, + Some(false) => -1.0, + None => 0.0, + } + } else { + 0.0 + } + } else { + 0.0 + }; + + if beta_val.abs() > 1e-15_f64 { + entries.push((src_i.clone(), src_j.clone(), beta_val)); + } + } + } + + if !entries.is_empty() { + result.insert(event.clone(), entries); + } + } + + result +} + +/// Build a decomposable DEM from propagated EEG generators. +/// +/// Same as `build_dem_configured` but includes X/Z component decomposition +/// for each mechanism, enabling proper graphlike decomposition for MWPM decoders. +#[must_use] +pub fn build_dem_decomposable( + generators: &[PropagatedEeg], + detectors: &[Detector], + observables: &[Observable], + stabilizer_group: Option<&StabilizerGroup>, + config: &EegConfig, +) -> Vec { + // First, build the standard DEM entries with the requested config + let standard_entries = + build_dem_configured(generators, detectors, observables, stabilizer_group, config); + + // Build a map from combined event → (x_component, z_component) + // by classifying each unique label's X/Z components. + // Multiple generators may contribute to the same event, but their + // X/Z classification should be consistent (same combined effect = same decomposition). + let mut event_xz: BTreeMap = BTreeMap::new(); + + for g in generators { + let (combined, x_ev, z_ev) = classify_xz(&g.label, detectors, observables); + let key = (combined.detectors.clone(), combined.observables.clone()); + // First generator for this event wins (they should all agree) + event_xz.entry(key).or_insert((x_ev, z_ev)); + } + + // Convert standard entries to decomposable entries + standard_entries + .into_iter() + .map(|entry| { + let key = ( + entry.event.detectors.clone(), + entry.event.observables.clone(), + ); + let (x_comp, z_comp) = event_xz.get(&key).cloned().unwrap_or((None, None)); + DecomposableDemEntry { + event: entry.event, + probability: entry.probability, + x_component: x_comp, + z_component: z_comp, + } + }) + .collect() +} + +/// Format DEM entries as a Stim-compatible string. +#[must_use] +pub fn format_dem(entries: &[DemEntry]) -> String { + let mut lines = Vec::new(); + for entry in entries { + let mut parts = Vec::new(); + for &d in &entry.event.detectors { + parts.push(format!("D{d}")); + } + for &o in &entry.event.observables { + parts.push(format!("L{o}")); + } + if !parts.is_empty() { + lines.push(format!( + "error({:.6e}) {}", + entry.probability, + parts.join(" ") + )); + } + } + lines.join("\n") +} + +/// Format a list of decomposable DEM entries into a graphlike DEM string. +/// +/// Mechanisms with both X and Z components are output as `error(p) x_targets ^ z_targets`. +/// Single-component mechanisms with ≤ 2 detectors are output directly. +/// Single-component hyperedges (3+ detectors) are decomposed via a graphlike +/// index: expressed as XOR of existing graphlike mechanisms. If no decomposition +/// exists, the mechanism is dropped (cannot be used by MWPM decoders). +#[must_use] +pub fn format_dem_decomposed(entries: &[DecomposableDemEntry]) -> String { + use std::collections::{BTreeMap, BTreeSet}; + + fn format_event(ev: &DemEvent) -> String { + let mut parts = Vec::new(); + for &d in &ev.detectors { + parts.push(format!("D{d}")); + } + for &o in &ev.observables { + parts.push(format!("L{o}")); + } + parts.join(" ") + } + + fn is_graphlike(ev: &DemEvent) -> bool { + ev.detectors.len() <= 2 + } + + // Search for decomposition of a hyperedge into XOR of graphlike pieces + fn search_decomp( + remaining: &DetectorSet, + by_det: &[Vec], + graphlike_set: &BTreeSet, + memo: &mut DecompMemo, + ) -> Option { + if let Some(cached) = memo.get(remaining) { + return cached.clone(); + } + if remaining.is_empty() { + let r = Some(Vec::new()); + memo.insert(remaining.clone(), r.clone()); + return r; + } + if remaining.len() <= 2 && graphlike_set.contains(remaining) { + let r = Some(vec![remaining.clone()]); + memo.insert(remaining.clone(), r.clone()); + return r; + } + + let pivot = remaining[0]; + if pivot >= by_det.len() { + memo.insert(remaining.clone(), None); + return None; + } + + for candidate in &by_det[pivot] { + // Candidate detectors must be subset of remaining + if !candidate.iter().all(|d| remaining.contains(d)) { + continue; + } + // XOR: remove shared detectors, keep symmetric difference + let mut next: SmallVec<[usize; 4]> = SmallVec::new(); + let mut i = 0; + let mut j = 0; + let r = remaining; + let c = candidate; + while i < r.len() && j < c.len() { + match r[i].cmp(&c[j]) { + std::cmp::Ordering::Less => { + next.push(r[i]); + i += 1; + } + std::cmp::Ordering::Greater => { + next.push(c[j]); + j += 1; + } + std::cmp::Ordering::Equal => { + i += 1; + j += 1; + } + } + } + while i < r.len() { + next.push(r[i]); + i += 1; + } + while j < c.len() { + next.push(c[j]); + j += 1; + } + + if next.len() >= remaining.len() { + continue; + } // must make progress + + if let Some(suffix) = search_decomp(&next, by_det, graphlike_set, memo) { + let mut result = vec![candidate.clone()]; + result.extend(suffix); + result.sort(); + let r = Some(result); + memo.insert(remaining.clone(), r.clone()); + return r; + } + } + + memo.insert(remaining.clone(), None); + None + } + + // Step 1: Collect all graphlike mechanisms (building blocks for decomposition) + let mut graphlike_set: BTreeSet> = BTreeSet::new(); + for entry in entries { + if entry.probability <= 0.0 { + continue; + } + // Collect graphlike from X/Z components + if let Some(ref x) = entry.x_component + && is_graphlike(x) + && !x.detectors.is_empty() + { + graphlike_set.insert(x.detectors.clone()); + } + if let Some(ref z) = entry.z_component + && is_graphlike(z) + && !z.detectors.is_empty() + { + graphlike_set.insert(z.detectors.clone()); + } + // Also from combined event + if is_graphlike(&entry.event) && !entry.event.detectors.is_empty() { + graphlike_set.insert(entry.event.detectors.clone()); + } + } + + // Step 2: Build index for graphlike decomposition search + let max_det = graphlike_set + .iter() + .flat_map(|d| d.iter().copied()) + .max() + .unwrap_or(0); + let mut by_det: Vec>> = vec![Vec::new(); max_det + 1]; + for g in &graphlike_set { + for &d in g { + by_det[d].push(g.clone()); + } + } + + // Step 3: Format entries + let mut by_targets: BTreeMap = BTreeMap::new(); + let mut memo: DecompMemo = BTreeMap::new(); + + for entry in entries { + if entry.probability <= 0.0 { + continue; + } + + let targets = match (&entry.x_component, &entry.z_component) { + (Some(x), Some(z)) if !x.detectors.is_empty() && !z.detectors.is_empty() => { + // Both components non-empty: decompose as X ^ Z + let x_str = format_event(x); + let z_str = format_event(z); + if x_str == z_str { + x_str + } else { + format!("{x_str} ^ {z_str}") + } + } + (Some(x), _) if !x.detectors.is_empty() || !x.observables.is_empty() => { + if is_graphlike(x) { + format_event(x) + } else { + // Hyperedge X-only: try graphlike decomposition + match search_decomp(&x.detectors, &by_det, &graphlike_set, &mut memo) { + Some(pieces) => { + let mut parts: Vec = pieces + .iter() + .map(|p| { + p.iter() + .map(|d| format!("D{d}")) + .collect::>() + .join(" ") + }) + .collect(); + // Attach observables to first piece + if !x.observables.is_empty() && !parts.is_empty() { + for &o in &x.observables { + let _ = write!(&mut parts[0], " L{o}"); + } + } + parts.join(" ^ ") + } + None => continue, // drop undecomposable mechanisms + } + } + } + (_, Some(z)) if !z.detectors.is_empty() || !z.observables.is_empty() => { + if is_graphlike(z) { + format_event(z) + } else { + // Hyperedge Z-only: try graphlike decomposition + match search_decomp(&z.detectors, &by_det, &graphlike_set, &mut memo) { + Some(pieces) => { + let mut parts: Vec = pieces + .iter() + .map(|p| { + p.iter() + .map(|d| format!("D{d}")) + .collect::>() + .join(" ") + }) + .collect(); + if !z.observables.is_empty() && !parts.is_empty() { + for &o in &z.observables { + let _ = write!(&mut parts[0], " L{o}"); + } + } + parts.join(" ^ ") + } + None => continue, // drop undecomposable mechanisms + } + } + } + _ => { + if is_graphlike(&entry.event) { + format_event(&entry.event) + } else { + // Combined hyperedge without components: try graphlike decomposition + match search_decomp(&entry.event.detectors, &by_det, &graphlike_set, &mut memo) + { + Some(pieces) => { + let mut parts: Vec = pieces + .iter() + .map(|p| { + p.iter() + .map(|d| format!("D{d}")) + .collect::>() + .join(" ") + }) + .collect(); + if !entry.event.observables.is_empty() && !parts.is_empty() { + for &o in &entry.event.observables { + let _ = write!(&mut parts[0], " L{o}"); + } + } + parts.join(" ^ ") + } + None => continue, + } + } + } + }; + + if targets.is_empty() { + continue; + } + + by_targets + .entry(targets) + .and_modify(|p| { + *p = *p + entry.probability - 2.0 * *p * entry.probability; + }) + .or_insert(entry.probability); + } + + let mut lines = Vec::new(); + for (targets, prob) in &by_targets { + if *prob > 0.0 { + lines.push(format!("error({prob:.6e}) {targets}")); + } + } + lines.join("\n") +} + +#[cfg(test)] +mod tests { + use super::*; + + fn z_det(id: usize, qubits: &[usize]) -> Detector { + let mut bm = Bm::default(); + for &q in qubits { + bm.z_bits.set_bit(q); + } + Detector { id, stabilizer: bm } + } + + #[test] + fn test_s_only_probability() { + // Single S_X with rate -0.01 → p ≈ 0.00995 + let gens = vec![PropagatedEeg { + eeg_type: EegType::S, + label: Bm::x(0), + label2: None, + coeff: -0.01, + source: None, + }]; + let dets = vec![z_det(0, &[0])]; // Z0 anticommutes with X0 + let entries = build_dem(&gens, &dets, &[]); + + assert_eq!(entries.len(), 1); + let expected = (1.0 - (-0.02_f64).exp()) / 2.0; + assert!((entries[0].probability - expected).abs() < 1e-6); + } + + #[test] + fn test_h_diagonal_probability() { + // H_X with rate 0.1 → p = 0.1^2 = 0.01 (diagonal approx) + let gens = vec![PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: 0.1, + source: None, + }]; + let dets = vec![z_det(0, &[0])]; + let entries = build_dem(&gens, &dets, &[]); + + assert_eq!(entries.len(), 1); + assert!((entries[0].probability - 0.01).abs() < 1e-10); + } + + #[test] + fn test_h_multiple_same_event() { + // Two H generators in same event class: rates don't add (diagonal approx) + // p = h1^2 + h2^2 (NOT (h1+h2)^2 — that would be coherent accumulation) + let gens = vec![ + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: 0.1, + source: None, + }, + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::y(0), + label2: None, + coeff: 0.05, + source: None, + }, + ]; + let dets = vec![z_det(0, &[0])]; // Both X0 and Y0 anticommute with Z0 + let entries = build_dem(&gens, &dets, &[]); + + // Diagonal: p = 0.1^2 + 0.05^2 = 0.01 + 0.0025 = 0.0125 + assert_eq!(entries.len(), 1); + assert!((entries[0].probability - 0.0125).abs() < 1e-10); + } + + #[test] + fn test_z_invisible_to_z_detector() { + // H_Z should NOT flip a Z-type detector (Z commutes with Z) + let gens = vec![PropagatedEeg { + eeg_type: EegType::H, + label: Bm::z(0), + label2: None, + coeff: 0.1, + source: None, + }]; + let dets = vec![z_det(0, &[0])]; + let entries = build_dem(&gens, &dets, &[]); + + assert!(entries.is_empty(), "Z should not flip Z detector"); + } + + #[test] + fn test_bch_same_label_accumulation() { + // Two H generators with SAME Pauli label: BCH sums coefficients. + // Two H_X(0) with rates 0.1 and 0.05 → combined rate 0.15 → p = 0.15^2 + let gens = vec![ + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: 0.1, + source: None, + }, + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: 0.05, + source: None, + }, + ]; + let dets = vec![z_det(0, &[0])]; + let entries = build_dem(&gens, &dets, &[]); + + // BCH combines: single generator with rate 0.15, p = 0.15^2 = 0.0225 + assert_eq!(entries.len(), 1); + assert!( + (entries[0].probability - 0.0225).abs() < 1e-10, + "BCH should sum same-label rates: got {}", + entries[0].probability + ); + } + + #[test] + fn test_beta_constructive_interference() { + // Two H generators Q1=X0, Q2=X1 in same event class (both flip Z0Z1). + // Q1*Q2 = X0X1. State is |Phi+> Bell state → X0X1 is +1 stabilizer. + // Constructive: p = (h1+h2)^2 + use crate::stabilizer::StabilizerGroup; + use pecos_core::gate_type::GateType; + use pecos_core::{Gate, GateAngles, GateParams, QubitId}; + + fn g(gt: GateType, qs: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qs.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } + } + + let gens = vec![ + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: 0.1, + source: None, + }, + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(1), + label2: None, + coeff: 0.05, + source: None, + }, + ]; + // Z0Z1 detector: X0 and X1 both anticommute with it + let dets = vec![Detector { + id: 0, + stabilizer: Bm::z(0).multiply(&Bm::z(1)), + }]; + + // |Phi+> = H CX |00> → stabilizers +X0X1, +Z0Z1 + let stab_group = + StabilizerGroup::from_circuit(&[g(GateType::H, &[0]), g(GateType::CX, &[0, 1])], 2); + + let entries = build_dem_with_stabilizers(&gens, &dets, &[], Some(&stab_group)); + + assert_eq!(entries.len(), 1); + // X0*X1 is +1 stabilizer → constructive: (0.1+0.05)^2 = 0.0225 + assert!( + (entries[0].probability - 0.0225).abs() < 1e-10, + "Constructive beta: got {}, expected 0.0225", + entries[0].probability + ); + } + + #[test] + fn test_beta_destructive_interference() { + // Same event class but |Phi-> state → X0X1 is -1 stabilizer. + // Destructive: p = (h1-h2)^2 + use crate::stabilizer::StabilizerGroup; + use pecos_core::gate_type::GateType; + use pecos_core::{Gate, GateAngles, GateParams, QubitId}; + + fn g(gt: GateType, qs: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qs.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } + } + + let gens = vec![ + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: 0.1, + source: None, + }, + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(1), + label2: None, + coeff: 0.05, + source: None, + }, + ]; + let dets = vec![Detector { + id: 0, + stabilizer: Bm::z(0).multiply(&Bm::z(1)), + }]; + + // |Phi-> = CX H X |00> → stabilizers -X0X1, +Z0Z1 + let stab_group = StabilizerGroup::from_circuit( + &[ + g(GateType::X, &[0]), + g(GateType::H, &[0]), + g(GateType::CX, &[0, 1]), + ], + 2, + ); + + let entries = build_dem_with_stabilizers(&gens, &dets, &[], Some(&stab_group)); + + assert_eq!(entries.len(), 1); + // X0*X1 is -1 stabilizer → destructive: (0.1-0.05)^2 = 0.0025 + assert!( + (entries[0].probability - 0.0025).abs() < 1e-10, + "Destructive beta: got {}, expected 0.0025", + entries[0].probability + ); + } + + #[test] + fn test_beta_equal_rates_destructive_cancels() { + // h1 = h2 = 0.1, -1 stabilizer product → p = (h1-h2)^2 = 0 + use crate::stabilizer::StabilizerGroup; + use pecos_core::gate_type::GateType; + use pecos_core::{Gate, GateAngles, GateParams, QubitId}; + + fn g(gt: GateType, qs: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qs.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } + } + + let gens = vec![ + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: 0.1, + source: None, + }, + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(1), + label2: None, + coeff: 0.1, + source: None, + }, + ]; + let dets = vec![Detector { + id: 0, + stabilizer: Bm::z(0).multiply(&Bm::z(1)), + }]; + + let stab_group = StabilizerGroup::from_circuit( + &[ + g(GateType::X, &[0]), + g(GateType::H, &[0]), + g(GateType::CX, &[0, 1]), + ], + 2, + ); + + let entries = build_dem_with_stabilizers(&gens, &dets, &[], Some(&stab_group)); + + // Complete cancellation: p = 0 + assert!( + entries.is_empty(), + "Equal-rate destructive interference should cancel completely" + ); + } + + #[test] + fn test_beta_no_stabilizer_diagonal() { + // When product is NOT in stabilizer group, beta = 0, fall back to diagonal. + // X0 and X1 both flip Z0Z1 detector. Product X0X1. + // State |00> has stabilizers Z0, Z1. X0X1 is not a stabilizer. + use crate::stabilizer::StabilizerGroup; + + let gens = vec![ + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: 0.1, + source: None, + }, + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(1), + label2: None, + coeff: 0.05, + source: None, + }, + ]; + let dets = vec![Detector { + id: 0, + stabilizer: Bm::z(0).multiply(&Bm::z(1)), + }]; + + let stab_group = StabilizerGroup::from_circuit(&[], 2); + + let entries = build_dem_with_stabilizers(&gens, &dets, &[], Some(&stab_group)); + + assert_eq!(entries.len(), 1); + // p = h1^2 + h2^2 = 0.0125 (diagonal only) + assert!( + (entries[0].probability - 0.0125).abs() < 1e-10, + "Non-stabilizer product → diagonal: got {}", + entries[0].probability + ); + } + + #[test] + fn test_s_exact_formula_multiple() { + // Multiple S generators in same event class: exact formula + // p = (1/2)(1 - exp(2 * sum_rates)) + let gens = vec![ + PropagatedEeg { + eeg_type: EegType::S, + label: Bm::x(0), + label2: None, + coeff: -0.01, + source: None, + }, + PropagatedEeg { + eeg_type: EegType::S, + label: Bm::y(0), + label2: None, + coeff: -0.005, + source: None, + }, + ]; + let dets = vec![z_det(0, &[0])]; + let entries = build_dem(&gens, &dets, &[]); + + assert_eq!(entries.len(), 1); + // sum_rates = -0.01 + -0.005 = -0.015 (same event class, both flip Z0) + let expected = (1.0 - (2.0 * -0.015_f64).exp()) / 2.0; + assert!((entries[0].probability - expected).abs() < 1e-10); + } + + #[test] + fn test_observable_classification() { + // Generator that flips an observable but no detectors + let gens = vec![PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: 0.1, + source: None, + }]; + let dets = vec![z_det(0, &[1])]; // Detector on qubit 1 + let obs = vec![Observable { + id: 0, + pauli: Bm::z(0), + }]; // Observable Z0 + + let entries = build_dem(&gens, &dets, &obs); + + // X0 anticommutes with Z0 (observable) but not with Z1 (detector) + assert_eq!(entries.len(), 1); + assert!(entries[0].event.detectors.is_empty()); + assert_eq!(entries[0].event.observables.as_slice(), &[0]); + } + + #[test] + fn test_bch2_anticommuting_h_generators() { + // Two H generators with anticommuting labels: H_X and H_Z on same qubit. + // [H_X, H_Z] = -2i H_{XZ} = -2i H_Y → BCH2 adds imaginary H_Y. + // BCH coefficient: -i * h_X * h_Z. + // + // Both X and Z flip the Z detector. Y also flips Z detector. + // The imaginary BCH2 generator contributes to probability via + // re_product = re_j * re_k - im_j * im_k. + // + // Diagonal of imaginary generator: |im|² = (h_X * h_Z)². + // Cross with real generators: Re(re * (-im)) = re * im (but im is negative). + let h_x = 0.1; + let h_z = 0.05; + + let gens = vec![ + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: h_x, + source: None, + }, + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::z(0), + label2: None, + coeff: h_z, + source: None, + }, + ]; + let dets = vec![z_det(0, &[0])]; // Z detector: X and Y anticommute, Z commutes + + // Without BCH2: only X flips detector. p = h_x² = 0.01 + let entries_k1 = + build_dem_with_options(&gens, &dets, &[], None, HFormula::Taylor, BchOrder::First); + let p_k1: f64 = entries_k1.iter().map(|e| e.probability).sum(); + assert!( + (p_k1 - h_x * h_x).abs() < 1e-10, + "BCH1: p should be h_x² = {}, got {p_k1}", + h_x * h_x + ); + + // With BCH2: adds H_Y with imaginary coeff -i * h_x * h_z. + // Y anticommutes with Z detector → flips it. + // Diagonal of imaginary Y: (h_x * h_z)² = 0.000025. + // Cross-term X with imaginary Y: Re(h_x * (i * h_x * h_z)) = 0 (imaginary × real = imaginary, Re=0) + // Wait, im_Y = -h_x * h_z (negative, from sign fix). Cross: re_X * re_Y - im_X * im_Y. + // re_X = h_x, im_X = 0. re_Y = 0, im_Y = -h_x*h_z. + // re_product = h_x * 0 - 0 * (-h_x*h_z) = 0. Cross-term is zero. + // So BCH2 only adds the diagonal of the Y generator: |im_Y|² = (h_x * h_z)² = 0.000025. + let entries_k2 = + build_dem_with_options(&gens, &dets, &[], None, HFormula::Taylor, BchOrder::Second); + let p_k2: f64 = entries_k2.iter().map(|e| e.probability).sum(); + let expected_k2 = h_x * h_x + (h_x * h_z).powi(2); + assert!( + (p_k2 - expected_k2).abs() < 1e-10, + "BCH2: p should be h_x² + (h_x·h_z)² = {expected_k2}, got {p_k2}" + ); + + // BCH2 adds a small correction: 0.01 + 0.000025 = 0.010025 + assert!(p_k2 > p_k1, "BCH2 should add to probability"); + } +} diff --git a/exp/pecos-eeg/src/dem_simulator.rs b/exp/pecos-eeg/src/dem_simulator.rs new file mode 100644 index 000000000..2493d2efe --- /dev/null +++ b/exp/pecos-eeg/src/dem_simulator.rs @@ -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. + +//! DEM-based simulator: samples from a Detector Error Model and synthesizes +//! physical measurement bitstrings. +//! +//! This module provides the pure Rust implementation of DEM-based simulation. +//! Given a circuit (as `Vec`) and noise parameters, it: +//! 1. Builds a DEM via the EEG coherent backward mechanism extraction +//! 2. Samples detection events from the DEM +//! 3. Synthesizes physical measurement bitstrings matching the circuit's output +//! +//! # Performance +//! +//! The sampling step uses `ParsedDem::sample()` which is O(mechanisms) per shot. +//! For bulk sampling, use `to_dem_sampler()` for columnar bit-packed SIMD sampling. + +use crate::dem_generator::{DemContext, DemGenerator}; +use crate::expand::{ExpandedCircuit, GateIndex}; +use crate::noise::UniformNoise; +use pecos_core::Gate; +use pecos_core::gate_type::GateType; +use pecos_core::pauli::pauli_bitmask::BitmaskStorage; +use pecos_qec::fault_tolerance::dem_builder::ParsedDem; +use pecos_qec::fault_tolerance::fault_sampler::{ + RawMeasurementPlan, StochasticNoiseParams, symbolic_measurement_history, +}; +use pecos_quantum::TickCircuit; +use pecos_random::PecosRng; + +/// Metadata needed for measurement synthesis. +#[derive(Clone, Debug)] +pub struct CircuitMeasurementMeta { + /// Total number of physical measurements in the circuit. + pub num_measurements: usize, + /// Detector definitions: each is a list of measurement record offsets. + /// Offset is relative: absolute_index = num_measurements + offset. + pub detector_records: Vec>, + /// Observable definitions: same format as detectors. + pub observable_records: Vec>, +} + +/// Result of a DEM simulation run. +pub struct DemSimulationResult { + /// Per-shot measurement bitstrings (same format as gate-by-gate simulators). + pub measurements: Vec>, +} + +/// Run DEM-based simulation: build DEM, sample, produce measurement bitstrings. +/// +/// Two modes: +/// 1. **Stochastic path** (idle_rz == 0): builds a TickCircuit from gates + metadata, +/// uses `DemSampler::from_tick_circuit` with `OutputMode::RawMeasurements` for +/// proper non-deterministic handling and maximum performance. +/// 2. **Coherent path** (idle_rz > 0): uses EEG DemGenerator for DEM, then +/// ParsedDem sampler + measurement synthesis (EEG handles coherent noise). +/// +/// # Arguments +/// * `gates` - Circuit gates (from CommandQueue conversion) +/// * `noise` - Noise parameters +/// * `meta` - Circuit measurement metadata (num_measurements, detector/observable records) +/// * `generator` - Which DEM generator to use (for coherent path) +/// * `shots` - Number of shots to sample +/// * `seed` - Random seed +pub fn run_dem_simulation( + gates: &[Gate], + noise: &UniformNoise, + meta: &CircuitMeasurementMeta, + generator: &dyn DemGenerator, + shots: usize, + seed: u64, +) -> DemSimulationResult { + // Coherent noise: use EEG path (Heisenberg walks handle idle_rz) + if noise.idle_rz.abs() > 1e-15 { + return run_eeg_path(gates, noise, meta, generator, shots, seed); + } + + // Stochastic: use proper DemSampler with raw measurement output + try_stochastic_path(gates, noise, meta, shots, seed) + .expect("DEM simulation failed: could not build TickCircuit or DemSampler from circuit") +} + +/// Stochastic raw measurement path via RawMeasurementPlan. +/// +/// Builds a TickCircuit, runs symbolic simulation for MeasurementHistory, +/// then uses fault_sampler::RawMeasurementPlan for: +/// - Correct cross-reset measurement correlations (via SymbolicSparseStab PZ) +/// - Geometric/O(fired) fault sampling +/// - Raw measurement output matching gate-by-gate simulators +/// +/// Returns None if idle_rz > 0 (needs EEG path for coherent noise). +fn try_stochastic_path( + gates: &[Gate], + noise: &UniformNoise, + meta: &CircuitMeasurementMeta, + shots: usize, + seed: u64, +) -> Option { + // Only use stochastic path when no coherent noise + if noise.idle_rz.abs() > 1e-15 { + return None; + } + + // Build TickCircuit using typed API (proper measurement record tracking) + let mut tc = build_tick_circuit(gates, meta); + + // Compact ticks to reduce DAG complexity (critical for performance) + tc.compact_ticks(); + + let history = symbolic_measurement_history(&tc).ok()?; + + let noise_params = StochasticNoiseParams { + p1: noise.p1, + p2: noise.p2, + p_meas: noise.p_meas, + p_prep: noise.p_prep, + }; + let mechanisms = + pecos_qec::fault_tolerance::fault_sampler::build_fault_table(&tc, &noise_params).ok()?; + let plan = RawMeasurementPlan::new(&history, mechanisms); + + // Sample raw measurements (columnar, then extract rows) + let result = plan.sample(shots, seed); + let mut measurements = Vec::with_capacity(shots); + for shot in 0..shots { + let n = result.num_measurements(); + let mut meas = Vec::with_capacity(n); + for m in 0..n { + meas.push(u8::from(result.get(shot, m).0)); + } + measurements.push(meas); + } + + Some(DemSimulationResult { measurements }) +} + +/// Build a TickCircuit from flat gates + metadata using the typed API. +/// +/// Uses `.mz()` for measurement gates (properly tracks measurement records), +/// `.pz()` for prep gates, and `try_add_gate()` for all other gates. +/// After building all gates, creates detector/observable annotations using +/// the stored measurement references. This ensures the DagCircuit conversion +/// and DagFaultAnalyzer see proper structured annotations. +fn build_tick_circuit(gates: &[Gate], meta: &CircuitMeasurementMeta) -> TickCircuit { + use pecos_quantum::{Attribute, TickMeasRef}; + + let mut tc = TickCircuit::default(); + let mut all_meas_refs: Vec = Vec::new(); + + for gate in gates { + match gate.gate_type { + GateType::MZ => { + // Use the typed .mz() API to properly track measurement records + let qubits: Vec = gate.qubits.iter().copied().collect(); + let refs = tc.tick().mz(&qubits); + all_meas_refs.extend(refs); + } + GateType::PZ | GateType::QAlloc => { + let qubits: Vec = gate.qubits.iter().copied().collect(); + tc.tick().pz(&qubits); + } + _ => { + let mut tick = tc.tick(); + let _ = tick.try_add_gate(gate.clone()); + } + } + } + + // Create detector annotations from record definitions + for records in &meta.detector_records { + let det_refs: Vec = records + .iter() + .filter_map(|&rec| { + let abs_idx = (meta.num_measurements as i32 + rec) as usize; + all_meas_refs.get(abs_idx).copied() + }) + .collect(); + if !det_refs.is_empty() { + tc.detector(&det_refs); + } + } + + // Create observable annotations from record definitions + for records in &meta.observable_records { + let obs_refs: Vec = records + .iter() + .filter_map(|&rec| { + let abs_idx = (meta.num_measurements as i32 + rec) as usize; + all_meas_refs.get(abs_idx).copied() + }) + .collect(); + if !obs_refs.is_empty() { + tc.observable(&obs_refs); + } + } + + // Set metadata (for DemBuilder JSON fallback path) + tc.set_meta( + "num_measurements", + Attribute::String(meta.num_measurements.to_string()), + ); + if let Ok(det_json) = serde_json::to_string( + &meta + .detector_records + .iter() + .enumerate() + .map(|(id, recs)| serde_json::json!({"id": id, "records": recs})) + .collect::>(), + ) { + tc.set_meta("detectors", Attribute::String(det_json)); + } + if let Ok(obs_json) = serde_json::to_string( + &meta + .observable_records + .iter() + .enumerate() + .map(|(id, recs)| serde_json::json!({"id": id, "records": recs})) + .collect::>(), + ) { + tc.set_meta("observables", Attribute::String(obs_json)); + } + + tc +} + +/// EEG path: DEM generation + ParsedDem sampling + measurement synthesis. +/// +/// Used when coherent noise (idle_rz) is present and the stochastic path +/// cannot capture the noise accurately. +fn run_eeg_path( + gates: &[Gate], + noise: &UniformNoise, + meta: &CircuitMeasurementMeta, + generator: &dyn DemGenerator, + shots: usize, + seed: u64, +) -> DemSimulationResult { + // Expand circuit for EEG analysis + let expanded = crate::expand::expand_circuit(gates); + let gate_index = GateIndex::build(&expanded.gates, expanded.num_qubits); + + // Build detectors and observables from metadata + let detectors = build_detectors_from_meta(meta, &expanded); + let observables = build_observables_from_meta(meta, &expanded); + + // Generate DEM via trait + let ctx = DemContext { + gates: &expanded.gates, + expanded: &expanded, + gate_index: &gate_index, + detectors: &detectors, + observables: &observables, + }; + let output = generator.generate(&ctx, noise); + let dem_str = crate::dem_mapping::format_dem(&output.entries); + + // Parse DEM and build sampler + let parsed_dem: ParsedDem = dem_str.parse().unwrap_or_else(|_| ParsedDem::new()); + let sampler = parsed_dem.to_dem_sampler(); + + // Build measurement synthesis info + let synthesis_info = MeasurementSynthesisInfo::build(meta, &expanded); + + // Sample and synthesize + let mut rng = PecosRng::seed_from_u64(seed); + let mut measurements = Vec::with_capacity(shots); + + for _ in 0..shots { + let (det_events, obs_flips) = sampler.sample(&mut rng); + let meas = synthesis_info.synthesize(&det_events, &obs_flips, &mut rng); + measurements.push(meas); + } + + DemSimulationResult { measurements } +} + +/// Build EEG Detector structs from circuit metadata. +fn build_detectors_from_meta( + meta: &CircuitMeasurementMeta, + expanded: &ExpandedCircuit, +) -> Vec { + meta.detector_records + .iter() + .enumerate() + .map(|(id, records)| { + let mut bm = crate::Bm::default(); + for &rec in records { + let meas_idx = (meta.num_measurements as i32 + rec) as usize; + if meas_idx < expanded.measurement_qubit.len() { + let q = expanded.measurement_qubit[meas_idx]; + bm.z_bits.set_bit(q); + } + } + crate::dem_mapping::Detector { id, stabilizer: bm } + }) + .collect() +} + +/// Build EEG Observable structs from circuit metadata. +fn build_observables_from_meta( + meta: &CircuitMeasurementMeta, + expanded: &ExpandedCircuit, +) -> Vec { + meta.observable_records + .iter() + .enumerate() + .map(|(id, records)| { + let mut bm = crate::Bm::default(); + for &rec in records { + let meas_idx = (meta.num_measurements as i32 + rec) as usize; + if meas_idx < expanded.measurement_qubit.len() { + let q = expanded.measurement_qubit[meas_idx]; + bm.z_bits.set_bit(q); + } + } + crate::dem_mapping::Observable { id, pauli: bm } + }) + .collect() +} + +/// Precomputed info for synthesizing measurements from detection events. +struct MeasurementSynthesisInfo { + num_meas: usize, + /// For each measurement: Some((det_idx, other_meas_idx)) if determined by a detector. + /// other_meas_idx == usize::MAX means single-record detector. + meas_info: Vec>, + /// Which measurements are non-deterministic (need random coin). + is_non_det: Vec, + /// Observable measurement assignments: (meas_idx, obs_idx). + obs_meas_info: Vec<(usize, usize)>, +} + +impl MeasurementSynthesisInfo { + /// Build synthesis info from circuit metadata. + fn build(meta: &CircuitMeasurementMeta, _expanded: &ExpandedCircuit) -> Self { + let num_meas = meta.num_measurements; + let mut meas_info: Vec> = vec![None; num_meas]; + + // Build detector -> measurement mapping + for (det_idx, records) in meta.detector_records.iter().enumerate() { + let abs_records: Vec = records + .iter() + .map(|&r| (num_meas as i32 + r) as usize) + .filter(|&idx| idx < num_meas) + .collect(); + + if abs_records.len() == 2 { + let (earlier, later) = if abs_records[0] < abs_records[1] { + (abs_records[0], abs_records[1]) + } else { + (abs_records[1], abs_records[0]) + }; + if meas_info[later].is_none() { + meas_info[later] = Some((det_idx, earlier)); + } + } else if abs_records.len() == 1 { + let idx = abs_records[0]; + if meas_info[idx].is_none() { + meas_info[idx] = Some((det_idx, usize::MAX)); + } + } + } + + // Identify non-deterministic measurements + let mut is_non_det = vec![false; num_meas]; + for idx in 0..num_meas { + if meas_info[idx].is_none() { + is_non_det[idx] = true; + } + } + // Also: measurements referenced as "other" by a detector but not assigned themselves + for idx in 0..num_meas { + if let Some((_, other_idx)) = meas_info[idx] + && other_idx != usize::MAX + && other_idx < num_meas + && meas_info[other_idx].is_none() + { + is_non_det[other_idx] = true; + } + } + + // Observable measurement assignments + let mut obs_meas_info = Vec::new(); + for (obs_idx, records) in meta.observable_records.iter().enumerate() { + for &rec in records { + let idx = (num_meas as i32 + rec) as usize; + if idx < num_meas { + obs_meas_info.push((idx, obs_idx)); + } + } + } + + Self { + num_meas, + meas_info, + is_non_det, + obs_meas_info, + } + } + + /// Synthesize a measurement bitstring from detection events + observable flips. + fn synthesize(&self, det_events: &[bool], obs_flips: &[bool], rng: &mut PecosRng) -> Vec { + let mut meas = vec![0u8; self.num_meas]; + + // Random coins for non-deterministic measurements + for (idx, bit) in meas.iter_mut().enumerate().take(self.num_meas) { + if self.is_non_det[idx] { + *bit = u8::from(rng.random_bool(0.5)); + } + } + + // Assign measurements in index order (time order) + for idx in 0..self.num_meas { + if let Some((det_idx, other_idx)) = self.meas_info[idx] { + if det_idx < det_events.len() && det_events[det_idx] { + if other_idx == usize::MAX { + meas[idx] ^= 1; + } else if other_idx < self.num_meas { + meas[idx] = u8::from(det_events[det_idx]) ^ meas[other_idx]; + } + } else if other_idx != usize::MAX && other_idx < self.num_meas { + meas[idx] = meas[other_idx]; + } + } + } + + // Apply observable flips + for &(meas_idx, obs_idx) in &self.obs_meas_info { + if obs_idx < obs_flips.len() && obs_flips[obs_idx] { + meas[meas_idx] ^= 1; + } + } + + meas + } +} diff --git a/exp/pecos-eeg/src/eeg.rs b/exp/pecos-eeg/src/eeg.rs new file mode 100644 index 000000000..1f342f747 --- /dev/null +++ b/exp/pecos-eeg/src/eeg.rs @@ -0,0 +1,120 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! EEG types: generator kinds and accumulators. + +use crate::Bm; +use std::collections::BTreeMap; + +/// The four types of Elementary Error Generators. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash, PartialOrd, Ord)] +pub enum EegType { + /// Hamiltonian: H_P[rho] = -i[P, rho]. Coherent rotations. + H, + /// Stochastic: S_P[rho] = P rho P - rho. Pauli errors. + S, + /// Correlation: C_{P,Q}. Two-Pauli correlations. + C, + /// Active: A_{P,Q}. Phase-dependent interference. + A, +} + +/// A single Elementary Error Generator with coefficient. +#[derive(Clone, Debug)] +pub struct Eeg { + pub eeg_type: EegType, + pub label_p: Bm, + pub label_q: Bm, + pub coeff: f64, +} + +/// Accumulator for Hamiltonian EEGs (H_P type). +/// +/// Stores the sum of coefficients for each Pauli label. This is the +/// first-order BCH result: G_c = Sigma epsilon_P H_P. +#[derive(Clone, Debug, Default)] +pub struct HamiltonianAccumulator { + generators: BTreeMap, +} + +impl HamiltonianAccumulator { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Add H_P with coefficient epsilon. + /// Accumulates (first-order BCH = sum). + pub fn add(&mut self, label: Bm, coeff: f64) { + *self.generators.entry(label).or_insert(0.0) += coeff; + } + + #[must_use] + pub fn len(&self) -> usize { + self.generators.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.generators.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.generators.iter() + } + + pub fn prune(&mut self, threshold: f64) { + self.generators.retain(|_, c| c.abs() > threshold); + } +} + +/// Accumulator for Stochastic EEGs (S_P type). +/// +/// S-type generators commute, so first-order BCH is exact. +#[derive(Clone, Debug, Default)] +pub struct StochasticAccumulator { + generators: BTreeMap, +} + +impl StochasticAccumulator { + #[must_use] + pub fn new() -> Self { + Self::default() + } + + pub fn add(&mut self, label: Bm, coeff: f64) { + *self.generators.entry(label).or_insert(0.0) += coeff; + } + + #[must_use] + pub fn len(&self) -> usize { + self.generators.len() + } + + #[must_use] + pub fn is_empty(&self) -> bool { + self.generators.is_empty() + } + + pub fn iter(&self) -> impl Iterator { + self.generators.iter() + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_hamiltonian_accumulator() { + let mut acc = HamiltonianAccumulator::new(); + acc.add(Bm::z(0), 0.05); + acc.add(Bm::z(0), 0.03); + acc.add(Bm::x(1), 0.02); + + assert_eq!(acc.len(), 2); + let z0 = acc.generators.get(&Bm::z(0)).unwrap(); + assert!((z0 - 0.08).abs() < 1e-10); + } +} diff --git a/exp/pecos-eeg/src/expand.rs b/exp/pecos-eeg/src/expand.rs new file mode 100644 index 000000000..b0d5a7b0f --- /dev/null +++ b/exp/pecos-eeg/src/expand.rs @@ -0,0 +1,468 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Deferred measurement: expand a circuit by replacing mid-circuit +//! MZ+PZ with CX to auxiliary qubits, deferring all measurements to the end. +//! +//! After expansion, the circuit is purely Clifford (no mid-circuit +//! measurements), and error generators can be propagated straight through. + +use crate::Bm; +use pecos_core::gate_type::GateType; +use pecos_core::pauli::pauli_bitmask::BitmaskStorage; +use pecos_core::{Gate, GateAngles, GateParams, QubitId}; + +/// Result of circuit expansion. +pub struct ExpandedCircuit { + /// The expanded gate sequence (purely Clifford, no mid-circuit measurements). + pub gates: Vec, + /// Total number of qubits (original + auxiliary). + pub num_qubits: usize, + /// Number of original qubits. + pub num_original_qubits: usize, + /// Mapping: measurement record index → auxiliary qubit index. + /// measurement_qubit[k] = the auxiliary qubit whose Z-measurement at + /// the end gives the k-th measurement record. + pub measurement_qubit: Vec, + /// Mapping: measurement record index → original qubit that was measured. + /// original_measured_qubit[k] = the qubit in the original circuit that + /// the k-th MZ gate acted on. + pub original_measured_qubit: Vec, +} + +/// Expand a circuit by deferring mid-circuit measurements. +/// +/// For each MZ(q) followed by PZ(q), replaces with: +/// 1. CX(q, aux) — copy q's state to a fresh auxiliary qubit +/// 2. PZ(q) — reset q to |0> (kept as-is, since PZ after CX is valid) +/// +/// All auxiliary qubits are measured at the end via MZ. +/// Final data measurements (MZ not followed by PZ) are also deferred +/// to auxiliary qubits for uniformity. +pub fn expand_circuit(gates: &[Gate]) -> ExpandedCircuit { + // First pass: find the max qubit index to know where auxiliaries start + let max_qubit = gates + .iter() + .flat_map(|g| g.qubits.iter()) + .map(pecos_core::QubitId::index) + .max() + .unwrap_or(0); + let num_original = max_qubit + 1; + let mut next_aux = num_original; + + let mut expanded = Vec::with_capacity(gates.len() * 2); + let mut measurement_qubit = Vec::new(); + let mut original_measured_qubit = Vec::new(); + + // Identify which MZ gates are mid-circuit (followed by PZ on same qubit) + // vs final (not followed by any operation on that qubit, or followed by + // a different operation). + // + // Track ancilla qubits: those with a PZ after the first non-PZ gate. + // Only ancilla MZ gets a post-expansion PZ (measurement projection). + let mut ancilla_qubits = std::collections::HashSet::new(); + { + let mut past_init = false; + for g in gates { + if past_init && (g.gate_type == GateType::PZ || g.gate_type == GateType::QAlloc) { + for q in &g.qubits { + ancilla_qubits.insert(q.index()); + } + } + if g.gate_type != GateType::PZ && g.gate_type != GateType::QAlloc { + past_init = true; + } + } + } + + // Strategy: walk gates, when we see MZ(q): + // - Replace with CX(q, aux_new) where aux_new is a fresh auxiliary + // - For ancilla qubits: add PZ(q) to model measurement projection + // - Record that measurement k maps to aux_new + // - The auxiliary qubit is measured at the end + + let mut i = 0; + while i < gates.len() { + let gate = &gates[i]; + + match gate.gate_type { + GateType::MZ => { + // For each qubit in this MZ gate, create CX to auxiliary + for q in &gate.qubits { + let q_idx = q.index(); + let aux = next_aux; + next_aux += 1; + + // Initialize auxiliary: QAlloc(aux) + // Use QAlloc (not PZ) so the noise model can distinguish + // auxiliary initialization from original circuit resets. + expanded.push(make_gate(GateType::QAlloc, &[aux])); + + // CX(q, aux) — copy measurement info to auxiliary + expanded.push(make_gate(GateType::CX, &[q_idx, aux])); + + // For ancilla qubits: add PZ to model measurement projection. + // MZ projects to a Z eigenstate, destroying X/Y coherences. + // Without this, the last round's syndrome generators retain + // X on the measured ancilla, creating spurious correlations. + // For intermediate rounds this is redundant (circuit PZ follows). + // + // Data readout MZ does NOT get PZ: data qubit Z components + // must persist for correct generator labels (Z errors are + // invisible to Z-basis readout and must not be cleared). + if ancilla_qubits.contains(&q_idx) { + expanded.push(make_gate(GateType::PZ, &[q_idx])); + } + + // Record: this measurement maps to the auxiliary qubit + measurement_qubit.push(aux); + original_measured_qubit.push(q_idx); + } + } + GateType::PZ | GateType::QAlloc => { + // Keep resets — they re-initialize the qubit for the next round + expanded.push(gate.clone()); + } + _ => { + // All other gates pass through unchanged + expanded.push(gate.clone()); + } + } + + i += 1; + } + + // Add final measurements of all auxiliary qubits at the end + for &aux in &measurement_qubit { + expanded.push(make_gate(GateType::MZ, &[aux])); + } + + ExpandedCircuit { + gates: expanded, + num_qubits: next_aux, + num_original_qubits: num_original, + measurement_qubit, + original_measured_qubit, + } +} + +impl ExpandedCircuit { + /// Map an expanded-circuit Pauli back to the original circuit frame. + /// + /// X on auxiliary qubit `aux_k` → X on `original_measured_qubit[k]` + /// (because `CX(q, aux)` copies X from control to target: X on aux + /// in the expanded circuit corresponds to X on q in the original). + /// + /// Z on auxiliary qubits is dropped (doesn't correspond to original). + /// Components on original qubits pass through unchanged. + #[must_use] + pub fn map_to_original_frame(&self, p: &Bm) -> Bm { + let mut result = Bm::default(); + + // Copy components on original qubits directly + for q in 0..self.num_original_qubits { + if p.has_x(q) { + result.x_bits.set_bit(q); + } + if p.has_z(q) { + result.z_bits.set_bit(q); + } + } + + // Map X on auxiliary qubits to X on original measured qubits + for (meas_idx, &aux_q) in self.measurement_qubit.iter().enumerate() { + if p.has_x(aux_q) { + let orig_q = self.original_measured_qubit[meas_idx]; + result.x_bits.xor_bit(orig_q); // XOR because same qubit may be measured multiple times + } + // Z on aux is ignored (measurement projection absorbs Z) + } + + result + } +} + +/// Precomputed qubit-to-gate index for sparse backward traversal. +/// +/// For each qubit, stores the gate indices (in the flat gate list) that +/// touch it, sorted in ascending order. This enables the backward walk +/// to visit only gates on active qubits instead of scanning all gates. +pub struct GateIndex { + /// qubit_gates[q] = sorted Vec of gate indices touching qubit q. + qubit_gates: Vec>, + /// Which gates are expansion gates (no physical noise). + pub expansion_gates: Vec, +} + +impl GateIndex { + /// Build the index from a gate list (typically the expanded circuit). + #[must_use] + pub fn build(gates: &[Gate], num_qubits: usize) -> Self { + let mut qubit_gates = vec![Vec::new(); num_qubits]; + + for (i, gate) in gates.iter().enumerate() { + for q in &gate.qubits { + qubit_gates[q.index()].push(i as u32); + } + } + + // Identify expansion gates (QAlloc + subsequent CX + PZ) + let mut expansion = vec![false; gates.len()]; + for i in 0..gates.len() { + if gates[i].gate_type == GateType::QAlloc { + expansion[i] = true; + } + } + for i in 1..gates.len() { + if gates[i].gate_type == GateType::CX && gates[i - 1].gate_type == GateType::QAlloc { + let alloc_q = gates[i - 1].qubits[0].index(); + if gates[i].qubits.len() >= 2 && gates[i].qubits[1].index() == alloc_q { + expansion[i] = true; + if i + 1 < gates.len() + && gates[i + 1].gate_type == GateType::PZ + && gates[i + 1].qubits[0].index() == gates[i].qubits[0].index() + { + expansion[i + 1] = true; + } + } + } + } + + Self { + qubit_gates, + expansion_gates: expansion, + } + } + + /// Gate indices touching qubit `q` in reverse order (for backward walk). + pub fn gates_on_qubit_rev(&self, q: usize) -> impl Iterator + '_ { + self.qubit_gates + .get(q) + .into_iter() + .flat_map(|v| v.iter().copied().rev()) + } + + /// Is this gate an expansion gate (no physical noise)? + #[inline] + #[must_use] + pub fn is_expansion(&self, gate_idx: usize) -> bool { + self.expansion_gates.get(gate_idx).copied().unwrap_or(false) + } +} + +#[must_use] +pub fn make_gate(gt: GateType, qubits: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qubits.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } +} + +#[cfg(test)] +mod tests { + use super::*; + + fn gate(gt: GateType, qubits: &[usize]) -> Gate { + make_gate(gt, qubits) + } + + #[test] + fn test_expand_simple_mcm() { + // PZ(0), H(0), MZ(0), PZ(0), H(0), MZ(0) + // Two rounds: measure, reset, measure again + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::H, &[0]), + gate(GateType::MZ, &[0]), // → CX(0, aux0) + gate(GateType::PZ, &[0]), // reset + gate(GateType::H, &[0]), + gate(GateType::MZ, &[0]), // → CX(0, aux1) + ]; + + let expanded = expand_circuit(&gates); + + // Should have 3 qubits: original 0, aux 1, aux 2 + assert_eq!(expanded.num_original_qubits, 1); + assert_eq!(expanded.num_qubits, 3); + assert_eq!(expanded.measurement_qubit.len(), 2); + + // No MZ in the middle — only at the end + let mid_mz = expanded.gates[..expanded.gates.len() - 2] + .iter() + .filter(|g| g.gate_type == GateType::MZ) + .count(); + assert_eq!(mid_mz, 0, "No mid-circuit MZ in expanded circuit"); + + // Two MZ at the end (one per auxiliary) + let end_mz = expanded + .gates + .iter() + .rev() + .take_while(|g| g.gate_type == GateType::MZ) + .count(); + assert_eq!(end_mz, 2); + } + + #[test] + fn test_expand_preserves_cliffords() { + let gates = vec![ + gate(GateType::PZ, &[0, 1]), + gate(GateType::H, &[0]), + gate(GateType::CX, &[0, 1]), + ]; + + let expanded = expand_circuit(&gates); + + // No measurements → no expansion needed + assert_eq!(expanded.num_qubits, 2); + assert_eq!(expanded.measurement_qubit.len(), 0); + assert_eq!(expanded.gates.len(), 3); // same gates + } + + #[test] + fn test_measurement_qubit_mapping() { + // 2 qubits, measure both + let gates = vec![ + gate(GateType::PZ, &[0, 1]), + gate(GateType::H, &[0]), + gate(GateType::CX, &[0, 1]), + gate(GateType::MZ, &[0]), // meas record 0 → aux 2 + gate(GateType::MZ, &[1]), // meas record 1 → aux 3 + ]; + + let expanded = expand_circuit(&gates); + + assert_eq!(expanded.measurement_qubit, vec![2, 3]); + assert_eq!(expanded.num_qubits, 4); + } + + #[test] + fn test_map_to_original_frame_x_on_aux() { + // X on auxiliary → X on original measured qubit + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::MZ, &[0]), // meas 0 → aux 1 + ]; + let expanded = expand_circuit(&gates); + + // X on aux 1 maps to X on original qubit 0 + let p = Bm::x(1); // aux qubit + let mapped = expanded.map_to_original_frame(&p); + assert_eq!(mapped, Bm::x(0)); + } + + #[test] + fn test_map_to_original_frame_z_on_aux_dropped() { + // Z on auxiliary is dropped (measurement projection absorbs it) + let gates = vec![gate(GateType::PZ, &[0]), gate(GateType::MZ, &[0])]; + let expanded = expand_circuit(&gates); + + let p = Bm::z(1); // Z on aux + let mapped = expanded.map_to_original_frame(&p); + assert!(mapped.is_identity(), "Z on aux should be dropped"); + } + + #[test] + fn test_map_to_original_frame_original_passthrough() { + // Components on original qubits pass through unchanged + let gates = vec![gate(GateType::PZ, &[0, 1]), gate(GateType::MZ, &[0])]; + let expanded = expand_circuit(&gates); + + let p = Bm::x(0).multiply(&Bm::z(1)); // X0 Z1 + let mapped = expanded.map_to_original_frame(&p); + assert_eq!(mapped, Bm::x(0).multiply(&Bm::z(1))); + } + + #[test] + fn test_expand_final_only_mz() { + // Circuit with only final MZ (no mid-circuit measurement) + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::H, &[0]), + gate(GateType::MZ, &[0]), + ]; + let expanded = expand_circuit(&gates); + + // Still creates one aux qubit for the final MZ + assert_eq!(expanded.num_qubits, 2); + assert_eq!(expanded.measurement_qubit.len(), 1); + } + + #[test] + fn test_expansion_pz_for_ancilla_mz() { + // Circuit: PZ(0,1), H(1), CX(1,0), MZ(1), PZ(1), H(1), CX(1,0), MZ(1), MZ(0) + // Qubit 1 is ancilla (has mid-circuit PZ). Qubit 0 is data. + // Last-round MZ(1) should get expansion PZ(1). + // Final MZ(0) should NOT get expansion PZ. + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::H, &[1]), + gate(GateType::CX, &[1, 0]), + gate(GateType::MZ, &[1]), // round 1 syndrome + gate(GateType::PZ, &[1]), // reset + gate(GateType::H, &[1]), + gate(GateType::CX, &[1, 0]), + gate(GateType::MZ, &[1]), // round 2 syndrome (last round, no PZ after) + gate(GateType::MZ, &[0]), // data readout + ]; + + let expanded = expand_circuit(&gates); + + // Count PZ/QAlloc gates on qubit 1 in the expanded circuit + let resets_on_1: Vec<_> = expanded + .gates + .iter() + .filter(|g| { + (g.gate_type == GateType::PZ || g.gate_type == GateType::QAlloc) + && g.qubits.iter().any(|q| q.index() == 1) + }) + .collect(); + + // Should have: original PZ(1) init + expansion PZ(1) round 1 + circuit PZ(1) reset + // + expansion PZ(1) round 2 = 4 reset gates on qubit 1 + eprintln!("Resets on qubit 1: {} gates", resets_on_1.len()); + assert!( + resets_on_1.len() >= 4, + "Should have expansion PZ for last-round MZ(1): got {} on q1", + resets_on_1.len() + ); + + // Count resets on qubit 0 in expanded circuit + let resets_on_0: Vec<_> = expanded + .gates + .iter() + .filter(|g| { + (g.gate_type == GateType::PZ || g.gate_type == GateType::QAlloc) + && g.qubits.iter().any(|q| q.index() == 0) + }) + .collect(); + // Should have only: original PZ(0) init = 1 + eprintln!("Resets on qubit 0: {} gates", resets_on_0.len()); + assert_eq!( + resets_on_0.len(), + 1, + "Data qubit should NOT get expansion PZ" + ); + } + + #[test] + fn test_expand_multi_round_tracks_original_qubits() { + // Two rounds measuring qubit 0: both aux should map back to qubit 0 + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::MZ, &[0]), + gate(GateType::PZ, &[0]), + gate(GateType::MZ, &[0]), + ]; + let expanded = expand_circuit(&gates); + + assert_eq!(expanded.measurement_qubit.len(), 2); + assert_eq!(expanded.original_measured_qubit, vec![0, 0]); + } +} diff --git a/exp/pecos-eeg/src/heisenberg.rs b/exp/pecos-eeg/src/heisenberg.rs new file mode 100644 index 000000000..c68d9163b --- /dev/null +++ b/exp/pecos-eeg/src/heisenberg.rs @@ -0,0 +1,2748 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Backward Heisenberg propagation of detectors through noise. +//! +//! Computes exact detection probabilities by propagating the detector +//! observable BACKWARD through the expanded (measurement-deferred) circuit. +//! +//! Coherent (H-type) noise: at each source exp(h·H_P), the observable +//! transforms via unitary conjugation: +//! D → cos(2h)·D + i·sin(2h)·P·D (when D and P anticommute) +//! D → D (when D and P commute) +//! +//! Stochastic (S-type) noise: EEG rate s < 0, physical error +//! probability p = (1/2)(1 - exp(2s)). Heisenberg dual: +//! D → D (when D and P commute) +//! D → exp(2s)·D (when D and P anticommute) +//! +//! The cost is exponential in the number of anticommuting H-type noise +//! sources per detector (2^m terms), but this is typically manageable +//! for QEC circuits (m ~ 5-15). S-type noise does not increase term count. +//! +//! Both a Pauli-tracking walk (fast, exact) and a matrix-based method +//! (exact, limited to ~20 qubits) are provided. + +use crate::Bm; +use crate::noise::NoiseSpec; +use crate::stabilizer::StabilizerGroup; +use pecos_core::Gate; +use pecos_core::gate_type::GateType; +use pecos_core::pauli::pauli_bitmask::BitmaskStorage; +use smallvec::SmallVec; +use std::collections::BinaryHeap; + +const CX_PHASE: [[u8; 4]; 4] = [[0, 0, 0, 0], [0, 0, 3, 1], [0, 1, 0, 3], [0, 3, 1, 0]]; + +fn sign_parity(signs: [bool; N]) -> bool { + signs.into_iter().fold(false, |parity, sign| parity ^ sign) +} + +fn activate_qubit( + q: u16, + before_gate: u32, + active: &mut [bool], + visited: &mut [bool], + heap: &mut BinaryHeap, + gate_index: &crate::expand::GateIndex, +) { + let qu = q as usize; + if qu >= active.len() { + return; + } + active[qu] = true; + for gi in gate_index.gates_on_qubit_rev(qu) { + if gi >= before_gate { + continue; + } // already passed + let gi_usize = gi as usize; + if !visited[gi_usize] { + visited[gi_usize] = true; + heap.push(gi); + } + } +} + +/// Precomputed noise for a single gate: H-type injections + batched S-type scale. +/// +/// Instead of calling `noise_after_gate()` during the walk and processing +/// 15+ S-type injections individually, we precompute: +/// - H-type injections (kept as-is, they cause branching) +/// - A combined S-type scale factor per qubit-support pattern +/// +/// For uniform 2-qubit depolarizing at rate p: any term with non-identity +/// on either gate qubit gets scaled by `(1-2p/15)^8` (exactly 8 of 15 +/// Paulis anticommute for any non-identity support). Single-qubit: `(1-2p/3)^2`. +pub struct PrecomputedGateNoise { + /// H-type injections (cause term branching, can't be batched). + pub h_injections: SmallVec<[crate::noise::NoiseInjection; 2]>, + /// Combined S-type scale for terms with non-identity on qubit 0 only. + /// (For 1q gates, this is the full scale. For 2q, see q1_scale/both_scale.) + pub q0_scale: f64, + /// Qubit 0 index (for S-type fast path). + pub q0: u16, + /// Combined S-type scale for terms with non-identity on qubit 1 only (2q gates). + pub q1_scale: f64, + /// Qubit 1 index. + pub q1: u16, + /// Combined S-type scale for terms with non-identity on BOTH qubits. + pub both_scale: f64, + /// Number of qubits this gate acts on (0, 1, or 2). + pub num_gate_qubits: u8, +} + +/// Build a noise map: precomputed noise for each gate. +/// +/// Returns `None` for gates with no noise. Expansion gates are mostly +/// skipped, except expansion CX gates get p_meas noise on their control +/// qubit (the originally-measured qubit) to model mid-circuit measurement +/// errors that the expansion would otherwise lose. +pub fn build_noise_map( + gates: &[Gate], + noise: &dyn NoiseSpec, + expansion_gates: &[bool], +) -> Vec> { + let mut map = Vec::with_capacity(gates.len()); + + for (i, gate) in gates.iter().enumerate() { + if i < expansion_gates.len() && expansion_gates[i] { + map.push(None); + continue; + } + + let qubits: SmallVec<[usize; 4]> = + gate.qubits.iter().map(pecos_core::QubitId::index).collect(); + let injections = noise.noise_after_gate(i, gate.gate_type, &qubits); + + if injections.is_empty() { + map.push(None); + continue; + } + + let mut h_inj = SmallVec::new(); + // Collect S-type rates grouped by which qubits they touch. + // We compute a combined scale factor for each support pattern. + let mut s_rates_q0_only = Vec::new(); // S noise on q0 only + let mut s_rates_q1_only = Vec::new(); // S noise on q1 only + let mut s_rates_both = Vec::new(); // S noise on both q0 and q1 + let mut s_rates_other = Vec::new(); // S noise on other patterns + + let q0 = qubits.first().copied().unwrap_or(0) as u16; + let q1 = if qubits.len() >= 2 { + qubits[1] as u16 + } else { + q0 + }; + + for inj in &injections { + if inj.eeg_type == crate::eeg::EegType::S { + let rate = inj.rate; + // Classify by qubit support + let on_q0 = inj.label.has_x(q0 as usize) || inj.label.has_z(q0 as usize); + let on_q1 = qubits.len() >= 2 + && (inj.label.has_x(q1 as usize) || inj.label.has_z(q1 as usize)); + + // For each S injection with rate s (s < 0), the scale for + // anticommuting terms is (1 - 2*(-s)) = (1 + 2s). + // We need to count how many of the term's components + // anticommute. For a uniform depolarizing model this is + // predetermined by the support pattern. + // + // Instead of trying to batch analytically (which requires + // knowing the exact anticommutation count), we accumulate + // the log of the scale factors and compute the combined + // scale per support pattern. + // + // For now, just collect individual rates. + if on_q0 && !on_q1 { + s_rates_q0_only.push(rate); + } else if !on_q0 && on_q1 { + s_rates_q1_only.push(rate); + } else if on_q0 && on_q1 { + s_rates_both.push(rate); + } else { + s_rates_other.push(rate); + } + } else { + h_inj.push(inj.clone()); + } + } + + // Compute combined scale factors. + // A term anticommutes with S_P iff it has non-trivial overlap with P. + // + // For a term with non-identity on q0 only: + // - Anticommutes with S generators touching q0: all of q0_only + both + // - But WHICH ones anticommute depends on the specific Pauli. + // + // For uniform depolarizing, the count of anticommuting generators is + // deterministic given the support pattern. But for general S noise, we + // can't batch — fall back to individual processing. + // + // Optimization: if ALL S rates are the same (uniform depol), use + // closed-form. Otherwise, keep individual injections. + let all_s_same_rate = { + let all_s: Vec = s_rates_q0_only + .iter() + .chain(&s_rates_q1_only) + .chain(&s_rates_both) + .chain(&s_rates_other) + .copied() + .collect(); + !all_s.is_empty() && all_s.iter().all(|&r| (r - all_s[0]).abs() < 1e-20) + }; + + if all_s_same_rate && s_rates_other.is_empty() { + // Uniform depolarizing: use closed-form combined scale. + // For any non-identity on q0: 2 of {X,Y,Z} on q0 anticommute. + // For single-qubit: 3 S generators, 2 anticommute → scale = (1+2s)^2 + // For two-qubit: 15 S generators, 8 anticommute for any non-trivial → (1+2s)^8 + let total_s = s_rates_q0_only.len() + s_rates_q1_only.len() + s_rates_both.len(); + let s = s_rates_q0_only + .first() + .or(s_rates_q1_only.first()) + .or(s_rates_both.first()) + .copied() + .unwrap_or(0.0); + let p = -s; + let individual_scale = 1.0 - 2.0 * p; + + // Count anticommuting for each support pattern. + // For term with non-identity on q0 only: + // anti with q0-only S: 2 out of 3 (if 1q) or 2 out of 3 (for each A⊗I) + // anti with both S: depends on q1 part (commutes since term has I on q1) + // Total for 2q depol: q0_only anti=2 out of 3, both anti=q0 part anti * q1 commutes + // = 2*3 (from 3 A⊗I where 2 of 3 A anticommute on q0, B=I commutes) + // + 0 (from 3 I⊗B) + // + 2*3 (from 9 A⊗B where 2 of 3 A anticommute, B commutes since I on q1) + // Wait, {A, I_term} always commutes for B part. So: + // anti count = (anti on q0) * (total on q1 including I) + (comm on q0) * (anti on q1) + // For term I on q1: anti on q1 = 0. + // So anti count = 2 * 4 + 2 * 0 = 8 for 15 generators (excluding I⊗I). + // + // Actually, let me just precompute this properly. + // For 1q depol (3 generators): non-identity on q → 2 anticommute → (1-2p/3)^2 + // For 2q depol (15 generators): non-identity on either q → 8 anticommute → (1-2p/15)^8 + let n_anti = if total_s == 3 { + 2 + } else if total_s == 15 { + 8 + } else { + 0 + }; + if n_anti == 0 && total_s > 0 { + // Non-standard S count (e.g., 1 for p_meas, 1 for p_prep): + // can't batch — put in h_injections for individual processing. + for inj in &injections { + if inj.eeg_type == crate::eeg::EegType::S { + h_inj.push(inj.clone()); + } + } + } + let combined = individual_scale.powi(n_anti); + + map.push(Some(PrecomputedGateNoise { + h_injections: h_inj, + q0_scale: combined, + q0, + q1_scale: combined, + q1, + both_scale: combined, + num_gate_qubits: qubits.len().min(2) as u8, + })); + } else if s_rates_q0_only.is_empty() + && s_rates_q1_only.is_empty() + && s_rates_both.is_empty() + && s_rates_other.is_empty() + { + // H-type only, no S noise + if h_inj.is_empty() { + map.push(None); + } else { + map.push(Some(PrecomputedGateNoise { + h_injections: h_inj, + q0_scale: 1.0, + q0, + q1_scale: 1.0, + q1, + both_scale: 1.0, + num_gate_qubits: qubits.len().min(2) as u8, + })); + } + } else { + // Non-uniform S noise: keep individual injections as H-type + // (the walk handles them individually). + for inj in injections { + if inj.eeg_type == crate::eeg::EegType::S { + h_inj.push(inj); // process individually in walk + } + } + map.push(Some(PrecomputedGateNoise { + h_injections: h_inj, + q0_scale: 1.0, + q0, + q1_scale: 1.0, + q1, + both_scale: 1.0, + num_gate_qubits: qubits.len().min(2) as u8, + })); + } + } + + map +} + +/// Sparse Pauli: stores only qubits with non-identity Pauli. +/// For terms touching ~10-20 qubits out of 1000+, this is 10-100x +/// more compact than a dense bitmask, making clone/cmp/hash O(support). +/// +/// Stored as sorted lists of qubit indices for X and Z components. +/// Y on qubit q means q appears in BOTH x_qubits and z_qubits. +#[derive(Clone, Debug, Default)] +pub(crate) struct SparsePauli { + x_qubits: SmallVec<[u16; 16]>, + z_qubits: SmallVec<[u16; 16]>, +} + +impl SparsePauli { + pub(crate) fn from_bm(bm: &Bm) -> Self { + let mut sp = Self::default(); + let max_x = bm.x_bits.highest_set_bit().unwrap_or(0); + let max_z = bm.z_bits.highest_set_bit().unwrap_or(0); + let max_q = max_x.max(max_z); + for q in 0..=max_q { + if bm.has_x(q) { + sp.x_qubits.push(q as u16); + } + if bm.has_z(q) { + sp.z_qubits.push(q as u16); + } + } + sp + } + + pub(crate) fn to_bm(&self) -> Bm { + let mut bm = Bm::default(); + for &q in &self.x_qubits { + bm.x_bits.set_bit(q as usize); + } + for &q in &self.z_qubits { + bm.z_bits.set_bit(q as usize); + } + bm + } + + #[inline] + fn is_identity(&self) -> bool { + self.x_qubits.is_empty() && self.z_qubits.is_empty() + } + + #[inline] + fn has_x(&self, q: u16) -> bool { + self.x_qubits.binary_search(&q).is_ok() + } + + #[inline] + fn has_z(&self, q: u16) -> bool { + self.z_qubits.binary_search(&q).is_ok() + } + + /// Toggle x-bit at qubit q (insert if missing, remove if present). + fn toggle_x(&mut self, q: u16) { + match self.x_qubits.binary_search(&q) { + Ok(i) => { + self.x_qubits.remove(i); + } + Err(i) => { + self.x_qubits.insert(i, q); + } + } + } + + fn toggle_z(&mut self, q: u16) { + match self.z_qubits.binary_search(&q) { + Ok(i) => { + self.z_qubits.remove(i); + } + Err(i) => { + self.z_qubits.insert(i, q); + } + } + } + + /// Remove X at qubit q (for PZ backward: kill if has_x). + pub(crate) fn clear_x(&mut self, q: u16) { + if let Ok(i) = self.x_qubits.binary_search(&q) { + self.x_qubits.remove(i); + } + } + + pub(crate) fn clear_z(&mut self, q: u16) { + if let Ok(i) = self.z_qubits.binary_search(&q) { + self.z_qubits.remove(i); + } + } + + /// Check if this Pauli commutes with a single-qubit Z_q. + /// Full commutation check with another SparsePauli. + fn commutes_with(&self, other: &Self) -> bool { + // Symplectic inner product mod 2: + // count = |self.x ∩ other.z| + |self.z ∩ other.x| + // Commutes iff count is even. + let c1 = sorted_intersection_count(&self.x_qubits, &other.z_qubits); + let c2 = sorted_intersection_count(&self.z_qubits, &other.x_qubits); + (c1 + c2).is_multiple_of(2) + } +} + +impl PartialEq for SparsePauli { + fn eq(&self, other: &Self) -> bool { + self.x_qubits == other.x_qubits && self.z_qubits == other.z_qubits + } +} +impl Eq for SparsePauli {} + +impl std::hash::Hash for SparsePauli { + fn hash(&self, state: &mut H) { + self.x_qubits.as_slice().hash(state); + self.z_qubits.as_slice().hash(state); + } +} + +impl Ord for SparsePauli { + fn cmp(&self, other: &Self) -> std::cmp::Ordering { + self.x_qubits + .as_slice() + .cmp(other.x_qubits.as_slice()) + .then(self.z_qubits.as_slice().cmp(other.z_qubits.as_slice())) + } +} +impl PartialOrd for SparsePauli { + fn partial_cmp(&self, other: &Self) -> Option { + Some(self.cmp(other)) + } +} + +/// Count elements in the intersection of two sorted slices. +#[inline] +fn sorted_intersection_count(a: &[u16], b: &[u16]) -> u32 { + let (mut i, mut j) = (0, 0); + let mut count = 0u32; + while i < a.len() && j < b.len() { + match a[i].cmp(&b[j]) { + std::cmp::Ordering::Less => i += 1, + std::cmp::Ordering::Greater => j += 1, + std::cmp::Ordering::Equal => { + count += 1; + i += 1; + j += 1; + } + } + } + count +} + +impl SparsePauli { + /// Conjugate by Hadamard on qubit q: X↔Z, Y→-Y. + fn conjugate_h(&mut self, q: u16) -> bool { + let hx = self.has_x(q); + let hz = self.has_z(q); + if hx != hz { + // X→Z or Z→X: swap + self.toggle_x(q); + self.toggle_z(q); + } + // Y→-Y: sign flip when both X and Z + hx && hz + } + + /// Conjugate by CX(control, target). Returns sign_negative. + fn conjugate_cx(&mut self, c: u16, t: u16) -> bool { + let cx = self.has_x(c); + let cz = self.has_z(c); + let tx = self.has_x(t); + let tz = self.has_z(t); + if cx { + self.toggle_x(t); + } + if tz { + self.toggle_z(c); + } + // Sign from phase table (same formula as the fixed conjugate_cx) + let pc = u8::from(cx) + 2 * u8::from(cz); + let pt = u8::from(tx) + 2 * u8::from(tz); + let phase_c = if tz { CX_PHASE[pc as usize][2] } else { 0 }; + let phase_t = if cx { CX_PHASE[1][pt as usize] } else { 0 }; + (phase_c + phase_t) % 4 == 2 + } + + /// Conjugate by CZ(a, b). Returns sign_negative. + fn conjugate_cz(&mut self, a: u16, b: u16) -> bool { + let ax = self.has_x(a); + let bx = self.has_x(b); + let az = self.has_z(a); + let bz = self.has_z(b); + if bx { + self.toggle_z(a); + } + if ax { + self.toggle_z(b); + } + ax && bx && (az != bz) + } + + /// Conjugate by Pauli X on qubit q. + fn conjugate_pauli_x(&self, q: u16) -> bool { + self.has_z(q) + } + /// Conjugate by Pauli Y on qubit q. + fn conjugate_pauli_y(&self, q: u16) -> bool { + self.has_x(q) != self.has_z(q) + } + /// Conjugate by Pauli Z on qubit q. + fn conjugate_pauli_z(&self, q: u16) -> bool { + self.has_x(q) + } + + /// Conjugate by SZ on qubit q: X→Y, Y→-X, Z→Z. + fn conjugate_sz(&mut self, q: u16) -> bool { + if !self.has_x(q) { + return false; + } + let was_y = self.has_z(q); + self.toggle_z(q); + was_y + } + + /// Conjugate by SZdg on qubit q. + fn conjugate_szdg(&mut self, q: u16) -> bool { + if !self.has_x(q) { + return false; + } + let was_y = self.has_z(q); + self.toggle_z(q); + !was_y + } + + /// Conjugate by SX on qubit q. + fn conjugate_sx(&mut self, q: u16) -> bool { + let xq = self.has_x(q); + let zq = self.has_z(q); + if zq { + self.toggle_x(q); + } + !xq && zq + } + + /// Conjugate by SXdg on qubit q. + fn conjugate_sxdg(&mut self, q: u16) -> bool { + let xq = self.has_x(q); + let zq = self.has_z(q); + if zq { + self.toggle_x(q); + } + xq && zq + } + + /// Conjugate by SY on qubit q. + fn conjugate_sy(&mut self, q: u16) -> bool { + let xq = self.has_x(q); + let zq = self.has_z(q); + if xq != zq { + self.toggle_x(q); + self.toggle_z(q); + } + xq && !zq + } + + /// Conjugate by SYdg on qubit q. + fn conjugate_sydg(&mut self, q: u16) -> bool { + let xq = self.has_x(q); + let zq = self.has_z(q); + if xq != zq { + self.toggle_x(q); + self.toggle_z(q); + } + !xq && zq + } + + /// Conjugate by SWAP(a, b). + fn conjugate_swap(&mut self, a: u16, b: u16) { + let ax = self.has_x(a); + let az = self.has_z(a); + let bx = self.has_x(b); + let bz = self.has_z(b); + // Clear both + if ax { + self.clear_x(a); + } + if az { + self.clear_z(a); + } + if bx { + self.clear_x(b); + } + if bz { + self.clear_z(b); + } + // Set swapped + if bx { + self.toggle_x(a); + } + if bz { + self.toggle_z(a); + } + if ax { + self.toggle_x(b); + } + if az { + self.toggle_z(b); + } + } +} + +/// Apply backward (Heisenberg) gate conjugation: P → U† P U. +/// +/// The conjugation methods on `SparsePauli` use the Schrödinger convention +/// (P → U P U†), so for the backward walk we swap non-self-adjoint gates +/// to their adjoints: SZ↔SZdg, SX↔SXdg, SY↔SYdg, SZZ↔SZZdg, etc. +/// Self-adjoint gates (H, X, Y, Z, CX, CZ, SWAP, CY) are unchanged. +pub(crate) fn sparse_conjugate(p: &mut SparsePauli, gate: &Gate) -> Option { + if gate.qubits.is_empty() { + return None; + } + let q0 = gate.qubits[0].index() as u16; + match gate.gate_type { + // Self-adjoint single-qubit gates + GateType::H => Some(p.conjugate_h(q0)), + GateType::X => Some(p.conjugate_pauli_x(q0)), + GateType::Y => Some(p.conjugate_pauli_y(q0)), + GateType::Z => Some(p.conjugate_pauli_z(q0)), + // Non-self-adjoint single-qubit: swap to adjoint for backward + GateType::SZ => Some(p.conjugate_szdg(q0)), + GateType::SZdg => Some(p.conjugate_sz(q0)), + GateType::SX => Some(p.conjugate_sxdg(q0)), + GateType::SXdg => Some(p.conjugate_sx(q0)), + GateType::SY => Some(p.conjugate_sydg(q0)), + GateType::SYdg => Some(p.conjugate_sy(q0)), + // Self-adjoint two-qubit gates + GateType::CX => { + let q1 = gate.qubits[1].index() as u16; + Some(p.conjugate_cx(q0, q1)) + } + GateType::CZ => { + let q1 = gate.qubits[1].index() as u16; + Some(p.conjugate_cz(q0, q1)) + } + GateType::SWAP => { + let q1 = gate.qubits[1].index() as u16; + p.conjugate_swap(q0, q1); + Some(false) + } + // CY is self-adjoint: CY = SZdg(t) CX(c,t) SZ(t) — chain + GateType::CY => { + let q1 = gate.qubits[1].index() as u16; + let s1 = p.conjugate_sz(q1); + let s2 = p.conjugate_cx(q0, q1); + let s3 = p.conjugate_szdg(q1); + Some(sign_parity([s1, s2, s3])) + } + // Non-self-adjoint two-qubit: swap to adjoint for backward. + // SZZ backward = SZZdg forward = CX(q0,q1) SZdg(q1) CX(q0,q1) + GateType::SZZ => { + let q1 = gate.qubits[1].index() as u16; + let s1 = p.conjugate_cx(q0, q1); + let s2 = p.conjugate_szdg(q1); + let s3 = p.conjugate_cx(q0, q1); + Some(sign_parity([s1, s2, s3])) + } + // SZZdg backward = SZZ forward = CX(q0,q1) SZ(q1) CX(q0,q1) + GateType::SZZdg => { + let q1 = gate.qubits[1].index() as u16; + let s1 = p.conjugate_cx(q0, q1); + let s2 = p.conjugate_sz(q1); + let s3 = p.conjugate_cx(q0, q1); + Some(sign_parity([s1, s2, s3])) + } + // SXX backward = SXXdg forward = H(q0) H(q1) SZZdg H(q0) H(q1) + GateType::SXX => { + let q1 = gate.qubits[1].index() as u16; + let s1 = p.conjugate_h(q0); + let s2 = p.conjugate_h(q1); + let s3 = p.conjugate_cx(q0, q1); + let s4 = p.conjugate_szdg(q1); + let s5 = p.conjugate_cx(q0, q1); + let s6 = p.conjugate_h(q0); + let s7 = p.conjugate_h(q1); + Some(sign_parity([s1, s2, s3, s4, s5, s6, s7])) + } + // SXXdg backward = SXX forward + GateType::SXXdg => { + let q1 = gate.qubits[1].index() as u16; + let s1 = p.conjugate_h(q0); + let s2 = p.conjugate_h(q1); + let s3 = p.conjugate_cx(q0, q1); + let s4 = p.conjugate_sz(q1); + let s5 = p.conjugate_cx(q0, q1); + let s6 = p.conjugate_h(q0); + let s7 = p.conjugate_h(q1); + Some(sign_parity([s1, s2, s3, s4, s5, s6, s7])) + } + // SYY backward = SYYdg forward = SX(q0) SX(q1) SZZdg SXdg(q0) SXdg(q1) + GateType::SYY => { + let q1 = gate.qubits[1].index() as u16; + let s1 = p.conjugate_sxdg(q0); + let s2 = p.conjugate_sxdg(q1); + let s3 = p.conjugate_cx(q0, q1); + let s4 = p.conjugate_szdg(q1); + let s5 = p.conjugate_cx(q0, q1); + let s6 = p.conjugate_sx(q0); + let s7 = p.conjugate_sx(q1); + Some(sign_parity([s1, s2, s3, s4, s5, s6, s7])) + } + // SYYdg backward = SYY forward + GateType::SYYdg => { + let q1 = gate.qubits[1].index() as u16; + let s1 = p.conjugate_sx(q0); + let s2 = p.conjugate_sx(q1); + let s3 = p.conjugate_cx(q0, q1); + let s4 = p.conjugate_sz(q1); + let s5 = p.conjugate_cx(q0, q1); + let s6 = p.conjugate_sxdg(q0); + let s7 = p.conjugate_sxdg(q1); + Some(sign_parity([s1, s2, s3, s4, s5, s6, s7])) + } + // Gates that don't conjugate Paulis + GateType::PZ + | GateType::QAlloc + | GateType::QFree + | GateType::MZ + | GateType::MeasureFree + | GateType::MeasureLeaked + | GateType::I + | GateType::Idle => None, + other => panic!("EEG Heisenberg: unsupported gate type {other:?}"), + } +} + +/// A term in the Heisenberg-propagated detector expansion. +#[derive(Clone, Debug)] +struct HeisenbergTerm { + /// Pauli operator (sparse: only stores non-identity qubits). + pauli: SparsePauli, + /// Complex coefficient (real, imaginary). + coeff_re: f64, + coeff_im: f64, +} + +/// Compute detection probability via backward Heisenberg propagation. +/// +/// Operates on the EXPANDED circuit (from [`crate::expand`]). Expansion +/// gates are automatically detected and skipped for noise injection. +/// +/// Handles both H-type (coherent) and S-type (stochastic) noise. +/// +/// # Arguments +/// * `gates` - The expanded circuit gates +/// * `detector` - Detector as Z on auxiliary qubit(s) (expanded frame) +/// * `noise` - Noise specification +/// * `initial_stab` - Stabilizer group of |0...0⟩ +/// * `prune_threshold` - Drop terms with |coefficient| below this (0 for exact) +pub fn heisenberg_detection_probability( + gates: &[Gate], + detector: &Bm, + noise: &dyn NoiseSpec, + initial_stab: &StabilizerGroup, + prune_threshold: f64, +) -> f64 { + heisenberg_windowed(gates, detector, noise, initial_stab, prune_threshold, None) +} + +/// Backward Heisenberg with precomputed noise map and BTreeMap-based merging. +/// +/// Uses BTreeMap for continuous dedup — no separate +/// merge step. Terms are merged on insert via BTreeMap's O(log n) lookup. +/// Also uses batched S-type scaling from the precomputed noise map. +pub fn heisenberg_with_noise_map( + gates: &[Gate], + detector: &Bm, + noise_map: &[Option], + initial_stab: &StabilizerGroup, + prune_threshold: f64, +) -> f64 { + let mut terms = vec![HeisenbergTerm { + pauli: SparsePauli::from_bm(detector), + coeff_re: 1.0, + coeff_im: 0.0, + }]; + + // Conservative active-qubit bitmap + let max_qubit = gates + .iter() + .flat_map(|g| g.qubits.iter()) + .map(pecos_core::QubitId::index) + .max() + .unwrap_or(0) + + 1; + let mut active_qubits = vec![false; max_qubit]; + for &q in terms[0] + .pauli + .x_qubits + .iter() + .chain(terms[0].pauli.z_qubits.iter()) + { + if (q as usize) < active_qubits.len() { + active_qubits[q as usize] = true; + } + } + + let mut last_merge_count = 1usize; + let mut sin_branches: Vec = Vec::new(); + + for i in (0..gates.len()).rev() { + let gate = &gates[i]; + let gate_qs: SmallVec<[u16; 4]> = gate.qubits.iter().map(|q| q.index() as u16).collect(); + + let gate_touches_active = gate_qs + .iter() + .any(|&q| (q as usize) < active_qubits.len() && active_qubits[q as usize]); + + // Look up precomputed noise for this gate + let gate_noise = if gate_touches_active { + noise_map.get(i).and_then(|n| n.as_ref()) + } else { + None + }; + + if let Some(gn) = gate_noise { + // H-type injections (branching) + for inj in &gn.h_injections { + match inj.eeg_type { + crate::eeg::EegType::H => { + let h = inj.rate; + if h.abs() < 1e-20 { + continue; + } + let cos2h = (2.0 * h).cos(); + let sin2h = (2.0 * h).sin(); + + let single_z_qubit: Option = if inj.label.x_bits.is_zero() { + inj.label.z_bits.highest_set_bit().map(|q| q as u16) + } else { + None + }; + let noise_sparse = if single_z_qubit.is_none() { + Some(SparsePauli::from_bm(&inj.label)) + } else { + None + }; + + sin_branches.clear(); + let n = terms.len(); + for term in terms.iter_mut().take(n) { + let anticommutes = if let Some(q) = single_z_qubit { + term.pauli.has_x(q) + } else { + !term.pauli.commutes_with(noise_sparse.as_ref().unwrap()) + }; + if anticommutes { + let (sr, si) = (sin2h * term.coeff_re, sin2h * term.coeff_im); + let (dp, total_phase) = if let Some(q) = single_z_qubit { + let mut dp = term.pauli.clone(); + dp.toggle_z(q); + let has_x = term.pauli.has_x(q); + let has_z = term.pauli.has_z(q); + let phase = if has_x { + if has_z { 3u8 } else { 1 } + } else { + 0 + }; + (dp, (phase + 1) % 4) + } else { + let term_bm = term.pauli.to_bm(); + let (dp_bm, phase_exp) = + inj.label.multiply_with_phase(&term_bm); + (SparsePauli::from_bm(&dp_bm), (phase_exp + 1) % 4) + }; + let (new_re, new_im) = match total_phase { + 0 => (sr, si), + 1 => (-si, sr), + 2 => (-sr, -si), + 3 => (si, -sr), + _ => unreachable!(), + }; + sin_branches.push(HeisenbergTerm { + pauli: dp, + coeff_re: new_re, + coeff_im: new_im, + }); + term.coeff_re *= cos2h; + term.coeff_im *= cos2h; + } + } + // Merge sin branches: try binary search merge if terms + // are still sorted from last merge, else just extend. + for t in sin_branches.drain(..) { + for &q in t.pauli.x_qubits.iter().chain(t.pauli.z_qubits.iter()) { + let qu = q as usize; + if qu < active_qubits.len() { + active_qubits[qu] = true; + } + } + if last_merge_count == terms.len() { + match terms.binary_search_by(|p| p.pauli.cmp(&t.pauli)) { + Ok(idx) => { + terms[idx].coeff_re += t.coeff_re; + terms[idx].coeff_im += t.coeff_im; + } + Err(_) => { + terms.push(t); + } + } + } else { + terms.push(t); + } + } + } + crate::eeg::EegType::S => { + // Non-batched S-type (fallback for non-uniform noise) + let s = inj.rate; + if s.abs() < 1e-20 { + continue; + } + let p = -s; + let scale = 1.0 - 2.0 * p; + let single_q: Option = { + let xq = inj.label.x_bits.highest_set_bit(); + let zq = inj.label.z_bits.highest_set_bit(); + match (xq, zq) { + (Some(x), None) => Some(x as u16), + (None, Some(z)) => Some(z as u16), + (Some(x), Some(z)) if x == z => Some(x as u16), + _ => None, + } + }; + if let Some(q) = single_q { + let has_x_in_noise = inj.label.x_bits.highest_set_bit().is_some(); + let has_z_in_noise = inj.label.z_bits.highest_set_bit().is_some(); + for term in &mut terms { + let anti = (has_z_in_noise && term.pauli.has_x(q)) + || (has_x_in_noise && term.pauli.has_z(q)); + if anti { + term.coeff_re *= scale; + term.coeff_im *= scale; + } + } + } else { + let ns = SparsePauli::from_bm(&inj.label); + for term in &mut terms { + if !term.pauli.commutes_with(&ns) { + term.coeff_re *= scale; + term.coeff_im *= scale; + } + } + } + } + _ => {} + } + } + + // Batched S-type: apply combined scale factor + let s_scale = gn.q0_scale; // Same for all non-trivial support patterns + if (s_scale - 1.0).abs() > 1e-20 { + match gn.num_gate_qubits { + 1 => { + let q = gn.q0; + for term in &mut terms { + if term.pauli.has_x(q) || term.pauli.has_z(q) { + term.coeff_re *= s_scale; + term.coeff_im *= s_scale; + } + } + } + 2 => { + let q0 = gn.q0; + let q1 = gn.q1; + for term in &mut terms { + let on_q0 = term.pauli.has_x(q0) || term.pauli.has_z(q0); + let on_q1 = term.pauli.has_x(q1) || term.pauli.has_z(q1); + if on_q0 || on_q1 { + term.coeff_re *= s_scale; + term.coeff_im *= s_scale; + } + } + } + _ => {} + } + } + } + + // Step 2: Backward Clifford conjugation + if !gate_touches_active { + continue; + } + + match gate.gate_type { + GateType::PZ | GateType::QAlloc => { + terms.retain(|t| !gate_qs.iter().any(|&qi| t.pauli.has_x(qi))); + for t in &mut terms { + for &qi in &gate_qs { + t.pauli.clear_z(qi); + } + } + } + GateType::MZ => { + terms.retain(|t| !gate_qs.iter().any(|&qi| t.pauli.has_x(qi))); + } + _ => { + for t in &mut terms { + if let Some(sign_neg) = sparse_conjugate(&mut t.pauli, gate) + && sign_neg + { + t.coeff_re = -t.coeff_re; + t.coeff_im = -t.coeff_im; + } + for &q in t.pauli.x_qubits.iter().chain(t.pauli.z_qubits.iter()) { + let qu = q as usize; + if qu < active_qubits.len() { + active_qubits[qu] = true; + } + } + } + } + } + + // Prune + if prune_threshold > 0.0 { + let thresh_sq = prune_threshold * prune_threshold; + terms.retain(|t| t.coeff_re * t.coeff_re + t.coeff_im * t.coeff_im > thresh_sq); + } + + // Merge: sort + dedup. In-place, no allocation, cache-friendly. + // For typical term counts (~50-100), this beats both HashMap and + // BTreeMap due to zero allocation overhead and sequential access. + let should_merge = match gate.gate_type { + GateType::PZ | GateType::QAlloc | GateType::MZ => terms.len() > 4, + _ => terms.len() > last_merge_count * 2 && terms.len() > 16, + }; + if should_merge { + terms.sort_unstable_by(|a, b| a.pauli.cmp(&b.pauli)); + let mut write = 0; + for read in 1..terms.len() { + if terms[read].pauli == terms[write].pauli { + terms[write].coeff_re += terms[read].coeff_re; + terms[write].coeff_im += terms[read].coeff_im; + } else { + if terms[write].coeff_re.abs() > 1e-30 || terms[write].coeff_im.abs() > 1e-30 { + write += 1; + } + if write < read { + terms.swap(write, read); + } + } + } + let final_len = if !terms.is_empty() + && (terms[write].coeff_re.abs() > 1e-30 || terms[write].coeff_im.abs() > 1e-30) + { + write + 1 + } else if terms.is_empty() { + 0 + } else { + write + }; + terms.truncate(final_len); + last_merge_count = terms.len().max(1); + } + } + + // Evaluate + let mut expectation_re = 0.0; + for term in &terms { + let eigenvalue = if term.pauli.is_identity() { + 1.0 + } else { + let bm = term.pauli.to_bm(); + match initial_stab.is_stabilizer(&bm) { + Some(true) => 1.0, + Some(false) => -1.0, + None => 0.0, + } + }; + expectation_re += term.coeff_re * eigenvalue; + } + (0.5 * (1.0 - expectation_re)).clamp(0.0, 1.0) +} + +/// Backward Heisenberg with optional gate windowing. +/// +/// If `gate_window` is `Some((start, end))`, only walks gates in `[start, end)`. +/// Faster for large circuits but may miss long-range correlations. +/// Use `None` (or call [`heisenberg_detection_probability`]) for exact results. +pub fn heisenberg_windowed( + gates: &[Gate], + detector: &Bm, + noise: &dyn NoiseSpec, + initial_stab: &StabilizerGroup, + prune_threshold: f64, + gate_window: Option<(usize, usize)>, +) -> f64 { + // Start with the detector as a single sparse term + let mut terms = vec![HeisenbergTerm { + pauli: SparsePauli::from_bm(detector), + coeff_re: 1.0, + coeff_im: 0.0, + }]; + + // Identify expansion gates (virtual, no physical noise). + let expansion_gates = { + let mut exp = vec![false; gates.len()]; + if !gates.is_empty() && gates[0].gate_type == GateType::QAlloc { + exp[0] = true; + } + for i in 1..gates.len() { + if gates[i].gate_type == GateType::QAlloc { + exp[i] = true; + } + if gates[i].gate_type == GateType::CX && gates[i - 1].gate_type == GateType::QAlloc { + let aq = gates[i - 1].qubits[0].index(); + if gates[i].qubits.len() >= 2 && gates[i].qubits[1].index() == aq { + exp[i] = true; + if i + 1 < gates.len() + && gates[i + 1].gate_type == GateType::PZ + && gates[i + 1].qubits[0].index() == gates[i].qubits[0].index() + { + exp[i + 1] = true; + } + } + } + } + exp + }; + + let mut last_merge_count = 1usize; + // #3: Pre-allocate sin branches buffer, reused across noise sources + let mut sin_branches: Vec = Vec::new(); + + // Conservative active-qubit bitmap: once a qubit is active, stays active. + // This avoids the expensive per-term scan for gate relevance. + let max_qubit = gates + .iter() + .flat_map(|g| g.qubits.iter()) + .map(pecos_core::QubitId::index) + .max() + .unwrap_or(0) + + 1; + let mut active_qubits = vec![false; max_qubit]; + // Seed from detector + for &q in terms[0] + .pauli + .x_qubits + .iter() + .chain(terms[0].pauli.z_qubits.iter()) + { + if (q as usize) < active_qubits.len() { + active_qubits[q as usize] = true; + } + } + + // Walk backward through the circuit (optionally windowed) + let (walk_start, walk_end) = gate_window.unwrap_or((0, gates.len())); + for i in (walk_start..walk_end).rev() { + let gate = &gates[i]; + let gate_qs: SmallVec<[u16; 4]> = gate.qubits.iter().map(|q| q.index() as u16).collect(); + + // O(1) gate relevance check via bitmap (conservative: may visit some extra gates) + let gate_touches_active = gate_qs + .iter() + .any(|&q| (q as usize) < active_qubits.len() && active_qubits[q as usize]); + + // Step 1: Apply noise adjoint (skip expansion gates). + if !expansion_gates[i] && gate_touches_active { + let qubits_usize: SmallVec<[usize; 4]> = gate_qs.iter().map(|&q| q as usize).collect(); + let injections = noise.noise_after_gate(i, gate.gate_type, &qubits_usize); + + for inj in &injections { + match inj.eeg_type { + crate::eeg::EegType::H => { + let h = inj.rate; + if h.abs() < 1e-20 { + continue; + } + + let cos2h = (2.0 * h).cos(); + let sin2h = (2.0 * h).sin(); + + let single_z_qubit: Option = if inj.label.x_bits.is_zero() { + inj.label.z_bits.highest_set_bit().map(|q| q as u16) + } else { + None + }; + let noise_sparse = if single_z_qubit.is_none() { + Some(SparsePauli::from_bm(&inj.label)) + } else { + None + }; + + // #3: Reuse sin_branches buffer + sin_branches.clear(); + let n = terms.len(); + + for term in terms.iter_mut().take(n) { + let anticommutes = if let Some(q) = single_z_qubit { + term.pauli.has_x(q) + } else { + !term.pauli.commutes_with(noise_sparse.as_ref().unwrap()) + }; + + if anticommutes { + let (sr, si) = (sin2h * term.coeff_re, sin2h * term.coeff_im); + + let (dp, total_phase) = if let Some(q) = single_z_qubit { + let mut dp = term.pauli.clone(); + dp.toggle_z(q); + let has_x = term.pauli.has_x(q); + let has_z = term.pauli.has_z(q); + let phase = if has_x { + if has_z { 3u8 } else { 1 } + } else { + 0 + }; + (dp, (phase + 1) % 4) + } else { + let term_bm = term.pauli.to_bm(); + let (dp_bm, phase_exp) = + inj.label.multiply_with_phase(&term_bm); + (SparsePauli::from_bm(&dp_bm), (phase_exp + 1) % 4) + }; + + let (new_re, new_im) = match total_phase { + 0 => (sr, si), + 1 => (-si, sr), + 2 => (-sr, -si), + 3 => (si, -sr), + _ => unreachable!(), + }; + sin_branches.push(HeisenbergTerm { + pauli: dp, + coeff_re: new_re, + coeff_im: new_im, + }); + term.coeff_re *= cos2h; + term.coeff_im *= cos2h; + } + } + + // Update active bitmap BEFORE extending (only scan new branches) + for t in &sin_branches { + for &q in t.pauli.x_qubits.iter().chain(t.pauli.z_qubits.iter()) { + let qu = q as usize; + if qu < active_qubits.len() { + active_qubits[qu] = true; + } + } + } + terms.append(&mut sin_branches); + } + crate::eeg::EegType::S => { + let s = inj.rate; + if s.abs() < 1e-20 { + continue; + } + let p = -s; + let scale = 1.0 - 2.0 * p; + // For S-type, single-qubit specialization + let single_q: Option = { + let xq = inj.label.x_bits.highest_set_bit(); + let zq = inj.label.z_bits.highest_set_bit(); + match (xq, zq) { + (Some(x), None) => Some(x as u16), + (None, Some(z)) => Some(z as u16), + (Some(x), Some(z)) if x == z => Some(x as u16), + _ => None, + } + }; + + if let Some(q) = single_q { + // Single-qubit S noise: check just the one qubit + let has_x_in_noise = inj.label.x_bits.highest_set_bit().is_some(); + let has_z_in_noise = inj.label.z_bits.highest_set_bit().is_some(); + for term in &mut terms { + // Anticommutes if noise X overlaps term Z or noise Z overlaps term X + let anti = (has_z_in_noise && term.pauli.has_x(q)) + || (has_x_in_noise && term.pauli.has_z(q)); + if anti { + term.coeff_re *= scale; + term.coeff_im *= scale; + } + } + } else { + let noise_sparse = SparsePauli::from_bm(&inj.label); + for term in &mut terms { + if !term.pauli.commutes_with(&noise_sparse) { + term.coeff_re *= scale; + term.coeff_im *= scale; + } + } + } + } + _ => {} + } + + if prune_threshold > 0.0 { + terms.retain(|t| { + t.coeff_re * t.coeff_re + t.coeff_im * t.coeff_im + > prune_threshold * prune_threshold + }); + } + } + } + + // Step 2: Conjugate backward through the gate. + // #2: Skip gates that don't touch active qubits + if !gate_touches_active { + continue; + } + + match gate.gate_type { + // #4: Batch PZ/QAlloc — single pass through terms for all qubits + GateType::PZ | GateType::QAlloc => { + terms.retain(|t| !gate_qs.iter().any(|&qi| t.pauli.has_x(qi))); + for t in &mut terms { + for &qi in &gate_qs { + t.pauli.clear_z(qi); + } + } + } + GateType::MZ => { + terms.retain(|t| !gate_qs.iter().any(|&qi| t.pauli.has_x(qi))); + } + _ => { + for t in &mut terms { + if let Some(sign_neg) = sparse_conjugate(&mut t.pauli, gate) + && sign_neg + { + t.coeff_re = -t.coeff_re; + t.coeff_im = -t.coeff_im; + } + // Update active bitmap (CX can spread support to new qubits) + for &q in t.pauli.x_qubits.iter().chain(t.pauli.z_qubits.iter()) { + let qu = q as usize; + if qu < active_qubits.len() { + active_qubits[qu] = true; + } + } + } + } + } + + // Merge duplicate Pauli terms by sorting + linear scan. + let should_merge = match gate.gate_type { + GateType::PZ | GateType::QAlloc | GateType::MZ => terms.len() > 4, + _ => terms.len() > last_merge_count * 2 && terms.len() > 16, + }; + if should_merge { + terms.sort_unstable_by(|a, b| a.pauli.cmp(&b.pauli)); + let mut write = 0; + for read in 1..terms.len() { + if terms[read].pauli == terms[write].pauli { + let re = terms[read].coeff_re; + let im = terms[read].coeff_im; + terms[write].coeff_re += re; + terms[write].coeff_im += im; + } else { + if terms[write].coeff_re.abs() > 1e-30 || terms[write].coeff_im.abs() > 1e-30 { + write += 1; + } + if write < read { + terms.swap(write, read); + } + } + } + let final_len = + if terms[write].coeff_re.abs() > 1e-30 || terms[write].coeff_im.abs() > 1e-30 { + write + 1 + } else { + write + }; + terms.truncate(final_len); + last_merge_count = terms.len().max(1); + } + } + + // Evaluate: p_D = (1/2)(1 - Re(Σ c_j ⟨ψ|Q_j|ψ⟩)) + let mut expectation_re = 0.0; + + for term in &terms { + let eigenvalue = if term.pauli.is_identity() { + 1.0 + } else { + // Convert sparse back to Bm for stabilizer check + let bm = term.pauli.to_bm(); + match initial_stab.is_stabilizer(&bm) { + Some(true) => 1.0, + Some(false) => -1.0, + None => 0.0, + } + }; + + expectation_re += term.coeff_re * eigenvalue; + } + + let prob = 0.5 * (1.0 - expectation_re); + prob.clamp(0.0, 1.0) +} + +/// Backward Heisenberg with sparse gate traversal via precomputed index. +/// +/// Instead of iterating all gates, uses a `GateIndex` to visit only gates +/// on active qubits (qubits in any term's support). Maintains a binary +/// heap of pending gates and an active qubit set for O(1) relevance checks. +/// +/// For large circuits (d>=7), this is significantly faster than the linear +/// scan in [`heisenberg_windowed`] because most gates are irrelevant. +/// +/// Accepts an optional precomputed noise map. When provided, uses batched +/// S-type scaling (faster). When `None`, calls `noise.noise_after_gate()`. +pub fn heisenberg_sparse( + gates: &[Gate], + detector: &Bm, + noise: &dyn NoiseSpec, + initial_stab: &StabilizerGroup, + prune_threshold: f64, + gate_index: &crate::expand::GateIndex, + noise_map: Option<&[Option]>, +) -> f64 { + let mut terms = vec![HeisenbergTerm { + pauli: SparsePauli::from_bm(detector), + coeff_re: 1.0, + coeff_im: 0.0, + }]; + + // Active qubit set: union of all terms' support. + // Use a Vec for O(1) check (faster than BTreeSet for small qubit counts). + let num_qubits = gate_index.expansion_gates.len().min(gates.len()) + 64; + let mut active = vec![false; num_qubits.max(1)]; + + // Visited gate set: don't add the same gate to the heap twice. + let mut visited = vec![false; gates.len()]; + + // Populate initial active qubits and heap from detector support. + // Max-heap: pops largest gate index first (backward traversal). + let mut heap: BinaryHeap = BinaryHeap::new(); + + // Seed from detector — all gates on detector qubits are candidates + let total_gates = gates.len() as u32; + for &q in &terms[0].pauli.x_qubits { + activate_qubit( + q, + total_gates, + &mut active, + &mut visited, + &mut heap, + gate_index, + ); + } + for &q in &terms[0].pauli.z_qubits { + activate_qubit( + q, + total_gates, + &mut active, + &mut visited, + &mut heap, + gate_index, + ); + } + + let mut last_merge_count = 1usize; + let mut sin_branches: Vec = Vec::new(); + + // Walk backward: pop gates from heap in reverse order (largest index first) + while let Some(gate_idx) = heap.pop() { + let i = gate_idx as usize; + let gate = &gates[i]; + let gate_qs: SmallVec<[u16; 4]> = gate.qubits.iter().map(|q| q.index() as u16).collect(); + + // Step 1: Apply noise adjoint (skip expansion gates). + if !gate_index.is_expansion(i) { + // Get noise: from precomputed map if available, else dynamic + let precomputed = noise_map.and_then(|nm| nm.get(i).and_then(|n| n.as_ref())); + + // Get injections: from noise map or dynamic noise spec + let dynamic_injections = if precomputed.is_none() { + let qubits_usize: SmallVec<[usize; 4]> = + gate_qs.iter().map(|&q| q as usize).collect(); + noise.noise_after_gate(i, gate.gate_type, &qubits_usize) + } else { + Vec::new() + }; + let injections: &[crate::noise::NoiseInjection] = if let Some(gn) = precomputed { + &gn.h_injections + } else { + &dynamic_injections + }; + + for inj in injections { + match inj.eeg_type { + crate::eeg::EegType::H => { + let h = inj.rate; + if h.abs() < 1e-20 { + continue; + } + + let cos2h = (2.0 * h).cos(); + let sin2h = (2.0 * h).sin(); + + let single_z_qubit: Option = if inj.label.x_bits.is_zero() { + inj.label.z_bits.highest_set_bit().map(|q| q as u16) + } else { + None + }; + let noise_sparse = if single_z_qubit.is_none() { + Some(SparsePauli::from_bm(&inj.label)) + } else { + None + }; + + sin_branches.clear(); + let n = terms.len(); + + for term in terms.iter_mut().take(n) { + let anticommutes = if let Some(q) = single_z_qubit { + term.pauli.has_x(q) + } else { + !term.pauli.commutes_with(noise_sparse.as_ref().unwrap()) + }; + + if anticommutes { + let (sr, si) = (sin2h * term.coeff_re, sin2h * term.coeff_im); + + let (dp, total_phase) = if let Some(q) = single_z_qubit { + let mut dp = term.pauli.clone(); + dp.toggle_z(q); + let has_x = term.pauli.has_x(q); + let has_z = term.pauli.has_z(q); + let phase = if has_x { + if has_z { 3u8 } else { 1 } + } else { + 0 + }; + (dp, (phase + 1) % 4) + } else { + let term_bm = term.pauli.to_bm(); + let (dp_bm, phase_exp) = + inj.label.multiply_with_phase(&term_bm); + (SparsePauli::from_bm(&dp_bm), (phase_exp + 1) % 4) + }; + + let (new_re, new_im) = match total_phase { + 0 => (sr, si), + 1 => (-si, sr), + 2 => (-sr, -si), + 3 => (si, -sr), + _ => unreachable!(), + }; + + // Check if new term activates new qubits + for &q in &dp.x_qubits { + activate_qubit( + q, + gate_idx, + &mut active, + &mut visited, + &mut heap, + gate_index, + ); + } + for &q in &dp.z_qubits { + activate_qubit( + q, + gate_idx, + &mut active, + &mut visited, + &mut heap, + gate_index, + ); + } + + sin_branches.push(HeisenbergTerm { + pauli: dp, + coeff_re: new_re, + coeff_im: new_im, + }); + term.coeff_re *= cos2h; + term.coeff_im *= cos2h; + } + } + + terms.append(&mut sin_branches); + } + crate::eeg::EegType::S => { + // S-type: process individually. When using noise map, + // the batched scaling below handles the common case, + // but unbatchable S injections are placed in h_injections + // and must be processed here. + let s = inj.rate; + if s.abs() < 1e-20 { + continue; + } + let p = -s; + let scale = 1.0 - 2.0 * p; + + let single_q: Option = { + let xq = inj.label.x_bits.highest_set_bit(); + let zq = inj.label.z_bits.highest_set_bit(); + match (xq, zq) { + (Some(x), None) => Some(x as u16), + (None, Some(z)) => Some(z as u16), + (Some(x), Some(z)) if x == z => Some(x as u16), + _ => None, + } + }; + + if let Some(q) = single_q { + let has_x_in_noise = inj.label.x_bits.highest_set_bit().is_some(); + let has_z_in_noise = inj.label.z_bits.highest_set_bit().is_some(); + for term in &mut terms { + let anti = (has_z_in_noise && term.pauli.has_x(q)) + || (has_x_in_noise && term.pauli.has_z(q)); + if anti { + term.coeff_re *= scale; + term.coeff_im *= scale; + } + } + } else { + let noise_sparse = SparsePauli::from_bm(&inj.label); + for term in &mut terms { + if !term.pauli.commutes_with(&noise_sparse) { + term.coeff_re *= scale; + term.coeff_im *= scale; + } + } + } + } + _ => {} + } + } + + // Batched S-type scaling from noise map (much faster than per-injection) + if let Some(gn) = precomputed { + let s_scale = gn.q0_scale; + if (s_scale - 1.0).abs() > 1e-20 { + match gn.num_gate_qubits { + 1 => { + let q = gn.q0; + for term in &mut terms { + if term.pauli.has_x(q) || term.pauli.has_z(q) { + term.coeff_re *= s_scale; + term.coeff_im *= s_scale; + } + } + } + 2 => { + let q0 = gn.q0; + let q1 = gn.q1; + for term in &mut terms { + let on_q0 = term.pauli.has_x(q0) || term.pauli.has_z(q0); + let on_q1 = term.pauli.has_x(q1) || term.pauli.has_z(q1); + if on_q0 || on_q1 { + term.coeff_re *= s_scale; + term.coeff_im *= s_scale; + } + } + } + _ => {} + } + } + } + } + + // Step 2: Backward Clifford conjugation. + match gate.gate_type { + GateType::PZ | GateType::QAlloc => { + terms.retain(|t| !gate_qs.iter().any(|&qi| t.pauli.has_x(qi))); + for t in &mut terms { + for &qi in &gate_qs { + t.pauli.clear_z(qi); + } + } + } + GateType::MZ => { + terms.retain(|t| !gate_qs.iter().any(|&qi| t.pauli.has_x(qi))); + } + _ => { + for t in &mut terms { + if let Some(sign_neg) = sparse_conjugate(&mut t.pauli, gate) + && sign_neg + { + t.coeff_re = -t.coeff_re; + t.coeff_im = -t.coeff_im; + } + + // Activate any NEW qubits from conjugation (e.g., CX spreads Z) + for &q in t.pauli.x_qubits.iter().chain(t.pauli.z_qubits.iter()) { + activate_qubit( + q, + gate_idx, + &mut active, + &mut visited, + &mut heap, + gate_index, + ); + } + } + } + } + + // Prune + if prune_threshold > 0.0 { + let thresh_sq = prune_threshold * prune_threshold; + terms.retain(|t| t.coeff_re * t.coeff_re + t.coeff_im * t.coeff_im > thresh_sq); + } + + // Merge duplicate Pauli terms. + let should_merge = match gate.gate_type { + GateType::PZ | GateType::QAlloc | GateType::MZ => terms.len() > 4, + _ => terms.len() > last_merge_count * 2 && terms.len() > 16, + }; + if should_merge { + terms.sort_unstable_by(|a, b| a.pauli.cmp(&b.pauli)); + let mut write = 0; + for read in 1..terms.len() { + if terms[read].pauli == terms[write].pauli { + let re = terms[read].coeff_re; + let im = terms[read].coeff_im; + terms[write].coeff_re += re; + terms[write].coeff_im += im; + } else { + if terms[write].coeff_re.abs() > 1e-30 || terms[write].coeff_im.abs() > 1e-30 { + write += 1; + } + if write < read { + terms.swap(write, read); + } + } + } + let final_len = if !terms.is_empty() + && (terms[write].coeff_re.abs() > 1e-30 || terms[write].coeff_im.abs() > 1e-30) + { + write + 1 + } else if terms.is_empty() { + 0 + } else { + write + }; + terms.truncate(final_len); + last_merge_count = terms.len().max(1); + } + } + + // Evaluate + let mut expectation_re = 0.0; + for term in &terms { + let eigenvalue = if term.pauli.is_identity() { + 1.0 + } else { + let bm = term.pauli.to_bm(); + match initial_stab.is_stabilizer(&bm) { + Some(true) => 1.0, + Some(false) => -1.0, + None => 0.0, + } + }; + expectation_re += term.coeff_re * eigenvalue; + } + + let prob = 0.5 * (1.0 - expectation_re); + prob.clamp(0.0, 1.0) +} + +/// Convenience: expand an original circuit and compute detection probability. +pub fn heisenberg_detection_probability_from_circuit( + original_gates: &[Gate], + detector_meas_indices: &[usize], + noise: &dyn NoiseSpec, + num_original_qubits: usize, + prune_threshold: f64, +) -> f64 { + let expanded = crate::expand::expand_circuit(original_gates); + + let mut detector = Bm::default(); + for &m in detector_meas_indices { + if m < expanded.measurement_qubit.len() { + detector.z_bits.set_bit(expanded.measurement_qubit[m]); + } + } + + let init_gates: Vec = (0..num_original_qubits) + .map(|q| crate::expand::make_gate(GateType::PZ, &[q])) + .collect(); + let stab = StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + + heisenberg_detection_probability(&expanded.gates, &detector, noise, &stab, prune_threshold) +} + +/// Exact detection probability via matrix-based backward Heisenberg. +/// +/// Computes the backward adjoint using dense 2^n × 2^n complex matrix +/// multiplication. Exact for any circuit, but limited to ~20 expanded +/// qubits by memory. Useful as a reference/validation for the faster +/// Pauli-tracking walk ([`heisenberg_detection_probability_from_circuit`]). +pub fn heisenberg_exact_from_circuit( + original_gates: &[Gate], + detector_meas_indices: &[usize], + noise: &dyn NoiseSpec, + _num_original_qubits: usize, +) -> f64 { + let expanded = crate::expand::expand_circuit(original_gates); + let n = expanded.num_qubits; + + assert!( + n <= 20, + "Matrix Heisenberg requires 2^n memory; {n} qubits is too large. Use the Pauli-tracking walk for approximate results." + ); + + let dim = 1usize << n; + + // Build detector matrix: diagonal with Z eigenvalues on the detector aux qubits. + let mut obs_re = vec![0.0f64; dim * dim]; + let obs_im = vec![0.0f64; dim * dim]; + for i in 0..dim { + let mut eigenvalue = 1.0f64; + for &m in detector_meas_indices { + if m < expanded.measurement_qubit.len() { + let aux = expanded.measurement_qubit[m]; + if (i >> aux) & 1 == 1 { + eigenvalue = -eigenvalue; + } + } + } + obs_re[i * dim + i] = eigenvalue; + } + + // Identify expansion gates + let expansion_gates = find_expansion_gates(&expanded.gates); + + // Walk backward, applying adjoints via matrix multiplication. + let mut im = obs_im; + for idx in (0..expanded.gates.len()).rev() { + let g = &expanded.gates[idx]; + let qs: Vec = g.qubits.iter().map(pecos_core::QubitId::index).collect(); + + // Noise adjoint (skip expansion gates) + if !expansion_gates[idx] { + let injections = noise.noise_after_gate(idx, g.gate_type, &qs); + for inj in &injections { + if inj.eeg_type != crate::eeg::EegType::H { + continue; + } + if inj.rate.abs() < 1e-20 { + continue; + } + // RZ(θ) on the noise qubit, where θ = 2*rate + let theta = 2.0 * inj.rate; + // Find which qubit the noise acts on + let noise_q = if let Some(q) = inj.label.z_bits.highest_set_bit() { + q + } else if let Some(q) = inj.label.x_bits.highest_set_bit() { + q + } else { + continue; + }; + matrix_rz_adjoint(&mut obs_re, &mut im, noise_q, theta, n); + } + } + + // Gate adjoint + match g.gate_type { + GateType::PZ | GateType::QAlloc => { + matrix_pz_adjoint(&mut obs_re, &mut im, qs[0], n); + } + GateType::MZ => { + matrix_mz_adjoint(&mut obs_re, &mut im, qs[0], n); + } + GateType::H => { + matrix_h_adjoint(&mut obs_re, &mut im, qs[0], n); + } + GateType::CX if qs.len() >= 2 => { + matrix_cx_adjoint(&mut obs_re, &mut im, qs[0], qs[1], n); + } + _ => {} + } + } + + // ⟨0...0|O_backward|0...0⟩ = obs_re[0] + let expectation = obs_re[0]; + let prob = 0.5 * (1.0 - expectation); + prob.clamp(0.0, 1.0) +} + +// --- Matrix helpers for exact Heisenberg --- + +fn bit_to_f64(value: usize) -> f64 { + f64::from(u8::try_from(value).expect("bit value fits in u8")) +} + +fn matrix_rz_adjoint(re: &mut [f64], im: &mut [f64], q: usize, theta: f64, n: usize) { + let dim = 1usize << n; + for i in 0..dim { + let bi = bit_to_f64((i >> q) & 1); + for j in 0..dim { + let bj = bit_to_f64((j >> q) & 1); + let phase = (bi - bj) * theta; + if phase.abs() < 1e-20 { + continue; + } + let (cp, sp) = (phase.cos(), phase.sin()); + let idx = i * dim + j; + let (r, m) = (re[idx], im[idx]); + re[idx] = cp * r - sp * m; + im[idx] = sp * r + cp * m; + } + } +} + +fn matrix_pz_adjoint(re: &mut [f64], im: &mut [f64], q: usize, n: usize) { + let dim = 1usize << n; + let mask = 1usize << q; + for i in 0..dim { + let iq = (i >> q) & 1; + for j in 0..dim { + let jq = (j >> q) & 1; + let idx = i * dim + j; + if iq == jq { + let i0 = i & !mask; + let j0 = j & !mask; + let idx0 = i0 * dim + j0; + re[idx] = re[idx0]; + im[idx] = im[idx0]; + } else { + re[idx] = 0.0; + im[idx] = 0.0; + } + } + } +} + +fn matrix_mz_adjoint(re: &mut [f64], im: &mut [f64], q: usize, n: usize) { + let dim = 1usize << n; + for i in 0..dim { + let iq = (i >> q) & 1; + for j in 0..dim { + let jq = (j >> q) & 1; + if iq != jq { + let idx = i * dim + j; + re[idx] = 0.0; + im[idx] = 0.0; + } + } + } +} + +fn matrix_h_adjoint(re: &mut [f64], im: &mut [f64], q: usize, n: usize) { + let dim = 1usize << n; + let mask = 1usize << q; + let mut new_re = vec![0.0f64; dim * dim]; + let mut new_im = vec![0.0f64; dim * dim]; + for i in 0..dim { + let i0 = i & !mask; + let i1 = i | mask; + let iq = (i >> q) & 1; + for j in 0..dim { + let j0 = j & !mask; + let j1 = j | mask; + let jq = (j >> q) & 1; + let mut sr = 0.0; + let mut si = 0.0; + for a in 0..2usize { + for b in 0..2usize { + let ia = if a == 0 { i0 } else { i1 }; + let jb = if b == 0 { j0 } else { j1 }; + let sign = if (iq * a + b * jq).is_multiple_of(2) { + 0.5 + } else { + -0.5 + }; + let idx = ia * dim + jb; + sr += sign * re[idx]; + si += sign * im[idx]; + } + } + new_re[i * dim + j] = sr; + new_im[i * dim + j] = si; + } + } + re.copy_from_slice(&new_re); + im.copy_from_slice(&new_im); +} + +fn matrix_cx_adjoint(re: &mut [f64], im: &mut [f64], control: usize, target: usize, n: usize) { + let dim = 1usize << n; + let cmask = 1usize << control; + let tmask = 1usize << target; + let cx_perm = |i: usize| -> usize { if (i & cmask) != 0 { i ^ tmask } else { i } }; + let mut new_re = vec![0.0f64; dim * dim]; + let mut new_im = vec![0.0f64; dim * dim]; + for i in 0..dim { + let ci = cx_perm(i); + for j in 0..dim { + let cj = cx_perm(j); + new_re[i * dim + j] = re[ci * dim + cj]; + new_im[i * dim + j] = im[ci * dim + cj]; + } + } + re.copy_from_slice(&new_re); + im.copy_from_slice(&new_im); +} + +/// Identify expansion gate indices. +fn find_expansion_gates(gates: &[Gate]) -> Vec { + let mut exp = vec![false; gates.len()]; + if !gates.is_empty() && gates[0].gate_type == GateType::QAlloc { + exp[0] = true; + } + for i in 1..gates.len() { + if gates[i].gate_type == GateType::QAlloc { + exp[i] = true; + } + if gates[i].gate_type == GateType::CX && gates[i - 1].gate_type == GateType::QAlloc { + let aq = gates[i - 1].qubits[0].index(); + if gates[i].qubits.len() >= 2 && gates[i].qubits[1].index() == aq { + exp[i] = true; + if i + 1 < gates.len() + && gates[i + 1].gate_type == GateType::PZ + && gates[i + 1].qubits[0].index() == gates[i].qubits[0].index() + { + exp[i + 1] = true; + } + } + } + } + exp +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::expand; + use crate::noise::UniformNoise; + use pecos_core::{GateAngles, GateParams, QubitId}; + + fn gate(gt: GateType, qubits: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qubits.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } + } + + #[test] + fn test_d2_zbasis_heisenberg_original_circuit() { + // d=2 Z-basis surface code (2 rounds) — the circuit where forward EEG + // has a ~50% gap. Test if Heisenberg closes it. + // + // Circuit: 7 qubits (0-3 data, 4-6 ancilla) + // X-check ancillas: 4, 5 (H, CX, CX, H, MZ) + // Z-check ancilla: 6 (CX, CX, MZ) + let gates_orig = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::PZ, &[3]), + gate(GateType::PZ, &[4]), + gate(GateType::PZ, &[5]), + gate(GateType::PZ, &[6]), + // Round 1 + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::CX, &[1, 6]), + gate(GateType::CX, &[5, 3]), + gate(GateType::CX, &[3, 6]), + gate(GateType::CX, &[5, 2]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[0, 6]), + gate(GateType::CX, &[4, 0]), + gate(GateType::CX, &[2, 6]), + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::MZ, &[4]), + gate(GateType::MZ, &[5]), + gate(GateType::MZ, &[6]), + // Reset + gate(GateType::PZ, &[4]), + gate(GateType::PZ, &[5]), + gate(GateType::PZ, &[6]), + // Round 2 + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::CX, &[1, 6]), + gate(GateType::CX, &[5, 3]), + gate(GateType::CX, &[3, 6]), + gate(GateType::CX, &[5, 2]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[0, 6]), + gate(GateType::CX, &[4, 0]), + gate(GateType::CX, &[2, 6]), + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::MZ, &[4]), + gate(GateType::MZ, &[5]), + gate(GateType::MZ, &[6]), + // Final data readout + gate(GateType::MZ, &[0]), + gate(GateType::MZ, &[1]), + gate(GateType::MZ, &[2]), + gate(GateType::MZ, &[3]), + ]; + + let expanded = expand::expand_circuit(&gates_orig); + let theta = 0.05; + let noise = UniformNoise::coherent_only(theta); + + // Initial state stabilizer group: Z on each PZ-initialized qubit. + // At circuit start, all original qubits are |0⟩. + // (Aux qubits are QAlloc'd later during the circuit.) + let init_gates: Vec = (0..7).map(|q| gate(GateType::PZ, &[q])).collect(); + let stab = StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + + // D1: ancilla 4 round comparison (Z on aux for meas 0 and meas 3) + let aux_m0 = expanded.measurement_qubit[0]; // q4 round 1 + let aux_m3 = expanded.measurement_qubit[3]; // q4 round 2 + let mut det1 = Bm::default(); + det1.z_bits.set_bit(aux_m0); + det1.z_bits.set_bit(aux_m3); + + // D2: ancilla 5 round comparison + let aux_m1 = expanded.measurement_qubit[1]; // q5 round 1 + let aux_m4 = expanded.measurement_qubit[4]; // q5 round 2 + let mut det2 = Bm::default(); + det2.z_bits.set_bit(aux_m1); + det2.z_bits.set_bit(aux_m4); + + // Run Heisenberg for both detectors + let p1_heis = + heisenberg_detection_probability(&expanded.gates, &det1, &noise, &stab, 1e-10); + let p2_heis = + heisenberg_detection_probability(&expanded.gates, &det2, &noise, &stab, 1e-10); + + // For comparison: forward EEG + let eeg_result = crate::circuit::analyze_with_noise(&expanded.gates, &noise); + let dets = vec![ + crate::dem_mapping::Detector { + id: 1, + stabilizer: det1, + }, + crate::dem_mapping::Detector { + id: 2, + stabilizer: det2, + }, + ]; + let entries = crate::dem_mapping::build_dem_configured( + &eeg_result.generators, + &dets, + &[], + Some(&stab), + &crate::dem_mapping::EegConfig::default(), + ); + let mut eeg_d1 = 0.0; + let mut eeg_d2 = 0.0; + for e in &entries { + for &d in &e.event.detectors { + if d == 1 { + eeg_d1 += e.probability; + } + if d == 2 { + eeg_d2 += e.probability; + } + } + } + + eprintln!("\nd=2 Z-basis, theta={theta}:"); + eprintln!(" D1: Heisenberg={p1_heis:.6}, EEG={eeg_d1:.6}"); + eprintln!(" D2: Heisenberg={p2_heis:.6}, EEG={eeg_d2:.6}"); + + // Heisenberg should give DIFFERENT values for D1 and D2 + // (unlike EEG which gives them equal due to missing time-ordering) + if (p1_heis - p2_heis).abs() > 1e-6 { + eprintln!(" Heisenberg correctly distinguishes D1 and D2!"); + } + } + + #[test] + fn test_single_x_check_heisenberg() { + // Simplest X-check: 2 data + 1 ancilla, 2 rounds. + // Detector: Z on ancilla (qubit 2) — passes through both MZ(2) gates. + // The round-comparison detector fires when the two MZ outcomes differ. + let gates_orig = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + // Round 1 + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + gate(GateType::PZ, &[2]), + // Round 2 + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + ]; + + let theta = 0.05; + let noise = UniformNoise::coherent_only(theta); + + // Initial state: Z on each qubit + let init_gates: Vec = (0..3).map(|q| gate(GateType::PZ, &[q])).collect(); + let stab = StabilizerGroup::from_circuit(&init_gates, 3); + + // Detector: Z on ancilla qubit 2 (round-comparison) + let det = Bm::z(2); + + let p_heis = heisenberg_detection_probability(&gates_orig, &det, &noise, &stab, 0.0); + + eprintln!("\nSimple X-check (original circuit), theta={theta}:"); + eprintln!(" Heisenberg: {p_heis:.6}"); + } + + #[test] + fn test_bell_parity_exact() { + // Bell parity: PZ(0,1), H(0), CX(0,1), H(0), H(1), MZ(0), MZ(1) + // Parity detector: Z_0 * Z_1 (on original qubits) + // Exact answer: p = sin²(theta) + let gates_orig = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::H, &[0]), + gate(GateType::CX, &[0, 1]), + gate(GateType::H, &[0]), + gate(GateType::H, &[1]), + gate(GateType::MZ, &[0]), + gate(GateType::MZ, &[1]), + ]; + + // Parity detector: Z on both measured qubits (original frame) + let mut det = Bm::default(); + det.z_bits.set_bit(0); + det.z_bits.set_bit(1); + + // Initial state: Z on each qubit + let init_gates: Vec = (0..2).map(|q| gate(GateType::PZ, &[q])).collect(); + let stab = StabilizerGroup::from_circuit(&init_gates, 2); + + for &theta in &[0.01, 0.05, 0.1, 0.2, 0.5] { + let noise = UniformNoise::coherent_only(theta); + + let p = heisenberg_detection_probability(&gates_orig, &det, &noise, &stab, 0.0); + + let exact = theta.sin().powi(2); + let eeg_taylor = theta * theta; // leading-order EEG + + eprintln!( + "theta={theta:.2}: Heisenberg={p:.6}, exact={exact:.6}, Taylor={eeg_taylor:.6}" + ); + + // Heisenberg should match exact much better than Taylor + assert!( + (p - exact).abs() < 0.01, + "theta={theta}: Heisenberg {p:.6} vs exact {exact:.6}, diff={:.6}", + (p - exact).abs() + ); + } + } + + #[test] + fn test_exact_bell_parity() { + // Matrix-based exact Heisenberg should match sin²(θ) perfectly. + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::H, &[0]), + gate(GateType::CX, &[0, 1]), + gate(GateType::H, &[0]), + gate(GateType::H, &[1]), + gate(GateType::MZ, &[0]), + gate(GateType::MZ, &[1]), + ]; + + for &theta in &[0.01, 0.05, 0.1, 0.2, 0.5] { + let noise = crate::noise::UniformNoise::coherent_only(theta); + let p = heisenberg_exact_from_circuit(&gates, &[0, 1], &noise, 2); + let exact = theta.sin().powi(2); + assert!( + (p - exact).abs() < 1e-10, + "theta={theta}: exact_heisenberg {p:.10} vs sin²(θ) {exact:.10}" + ); + } + } + + #[test] + fn test_exact_2round_xcheck() { + // Matrix Heisenberg on the simplest failing case: 2-round, 1 ancilla. + // Exact analytical: P = [2 - cos(6θ) - cos(2θ)] / 4. + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + ]; + + for &theta in &[0.01, 0.05, 0.1, 0.2] { + let noise = crate::noise::UniformNoise::coherent_only(theta); + let p = heisenberg_exact_from_circuit(&gates, &[0, 1], &noise, 3); + let exact = (2.0 - (6.0 * theta).cos() - (2.0 * theta).cos()) / 4.0; + eprintln!("theta={theta:.2}: exact_heisenberg={p:.10}, analytical={exact:.10}"); + assert!( + (p - exact).abs() < 1e-8, + "theta={theta}: got {p:.10}, expected {exact:.10}, diff={:.2e}", + (p - exact).abs() + ); + } + } + + /// Verify heisenberg_sparse produces identical results to heisenberg_windowed, + /// and measure the speedup from sparse traversal. + #[test] + fn test_sparse_matches_windowed_and_timing() { + use std::time::Instant; + + // Build a d=2 Z-basis surface code with 2 rounds (same as test above) + let gates_orig = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::PZ, &[3]), + gate(GateType::PZ, &[4]), + gate(GateType::PZ, &[5]), + gate(GateType::PZ, &[6]), + // Round 1 + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::CX, &[1, 6]), + gate(GateType::CX, &[5, 3]), + gate(GateType::CX, &[3, 6]), + gate(GateType::CX, &[5, 2]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[0, 6]), + gate(GateType::CX, &[4, 0]), + gate(GateType::CX, &[2, 6]), + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::MZ, &[4]), + gate(GateType::MZ, &[5]), + gate(GateType::MZ, &[6]), + // Reset + Round 2 + gate(GateType::PZ, &[4]), + gate(GateType::PZ, &[5]), + gate(GateType::PZ, &[6]), + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::CX, &[1, 6]), + gate(GateType::CX, &[5, 3]), + gate(GateType::CX, &[3, 6]), + gate(GateType::CX, &[5, 2]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[0, 6]), + gate(GateType::CX, &[4, 0]), + gate(GateType::CX, &[2, 6]), + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::MZ, &[4]), + gate(GateType::MZ, &[5]), + gate(GateType::MZ, &[6]), + ]; + + let expanded = crate::expand::expand_circuit(&gates_orig); + let gate_index = crate::expand::GateIndex::build(&expanded.gates, expanded.num_qubits); + + let init_gates: Vec = (0..7).map(|q| gate(GateType::PZ, &[q])).collect(); + let stab = + crate::stabilizer::StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + + // Test both coherent-only and depolarizing noise + let noise_configs: Vec<(&str, crate::noise::UniformNoise)> = vec![ + ( + "coherent_only", + crate::noise::UniformNoise::coherent_only(0.05), + ), + ( + "depolarizing", + crate::noise::UniformNoise { + idle_rz: 0.0, + p1: 0.001, + p2: 0.01, + p_meas: 0.001, + p_prep: 0.001, + }, + ), + ( + "combined", + crate::noise::UniformNoise { + idle_rz: 0.05, + p1: 0.001, + p2: 0.01, + p_meas: 0.001, + p_prep: 0.001, + }, + ), + ]; + + for (label, noise) in &noise_configs { + let noise_map = build_noise_map(&expanded.gates, noise, &gate_index.expansion_gates); + + // Test all 3 detectors (auxiliary qubits in round 1: meas 0,1,2) + for meas_idx in 0..3 { + let aux_q = expanded.measurement_qubit[meas_idx]; + let det = Bm::z(aux_q); + + // Windowed (old path) + let start = Instant::now(); + let p_windowed = + heisenberg_windowed(&expanded.gates, &det, noise, &stab, 1e-12, None); + let t_windowed = start.elapsed(); + + // Sparse without noise map + let start = Instant::now(); + let p_sparse = heisenberg_sparse( + &expanded.gates, + &det, + noise, + &stab, + 1e-12, + &gate_index, + None, + ); + let t_sparse = start.elapsed(); + + // Sparse with noise map + let start = Instant::now(); + let p_sparse_nm = heisenberg_sparse( + &expanded.gates, + &det, + noise, + &stab, + 1e-12, + &gate_index, + Some(&noise_map), + ); + let t_sparse_nm = start.elapsed(); + + // With noise map (old path) + let start = Instant::now(); + let p_nm = + heisenberg_with_noise_map(&expanded.gates, &det, &noise_map, &stab, 1e-12); + let t_nm = start.elapsed(); + + // Verify exact match + let tol = 1e-12; + assert!( + (p_windowed - p_sparse).abs() < tol, + "{label} det{meas_idx}: windowed={p_windowed:.15} vs sparse={p_sparse:.15}, diff={:.2e}", + (p_windowed - p_sparse).abs() + ); + assert!( + (p_windowed - p_sparse_nm).abs() < tol, + "{label} det{meas_idx}: windowed={p_windowed:.15} vs sparse+nm={p_sparse_nm:.15}, diff={:.2e}", + (p_windowed - p_sparse_nm).abs() + ); + assert!( + (p_windowed - p_nm).abs() < tol, + "{label} det{meas_idx}: windowed={p_windowed:.15} vs nm={p_nm:.15}, diff={:.2e}", + (p_windowed - p_nm).abs() + ); + + eprintln!( + " {label} det{meas_idx}: p={p_windowed:.8} \ + windowed={:.1}us sparse={:.1}us sparse+nm={:.1}us nm={:.1}us", + t_windowed.as_secs_f64() * 1e6, + t_sparse.as_secs_f64() * 1e6, + t_sparse_nm.as_secs_f64() * 1e6, + t_nm.as_secs_f64() * 1e6 + ); + } + } + } + + /// Scaling benchmark: sparse vs windowed at d=3..11 repetition codes. + /// + /// Builds larger circuits and measures per-detector walk time with both + /// implementations. Verifies results match exactly. + #[test] + #[ignore = "benchmark; run manually with --ignored --nocapture"] + fn bench_sparse_scaling() { + use std::time::Instant; + + let noise = crate::noise::UniformNoise { + idle_rz: 0.05, + p1: 0.001, + p2: 0.01, + p_meas: 0.001, + p_prep: 0.001, + }; + + eprintln!("\n=== Sparse vs Windowed scaling (combined noise) ==="); + eprintln!( + "{:>4} {:>6} {:>8} {:>8} {:>6} {:>12} {:>12} {:>8}", + "d", "rnds", "gates", "exp_q", "n_det", "windowed_ms", "sparse_ms", "speedup" + ); + + // Test with increasing circuit sizes. + // Repetition codes are 1D — detectors propagate through most gates. + // Surface codes are 2D — detectors are local (touch ~8 out of d^2 qubits). + // Test both to show where sparsity helps. + + // --- Repetition codes (1D, low sparsity) --- + eprintln!("\n--- Repetition codes (1D) ---"); + let rep_configs: Vec<(usize, usize)> = + vec![(5, 3), (5, 10), (9, 3), (9, 10), (13, 3), (13, 10)]; + + for &(d, num_rounds) in &rep_configs { + let num_data = d; + let num_ancilla = d - 1; + let num_qubits = num_data + num_ancilla; + + // Build repetition code + let mut gates = Vec::new(); + for q in 0..num_qubits { + gates.push(gate(GateType::PZ, &[q])); + } + for round in 0..num_rounds { + for i in 0..num_ancilla { + gates.push(gate(GateType::H, &[num_data + i])); + } + for i in 0..num_ancilla { + gates.push(gate(GateType::CX, &[num_data + i, i])); + } + for i in 0..num_ancilla { + gates.push(gate(GateType::CX, &[num_data + i, i + 1])); + } + for i in 0..num_ancilla { + gates.push(gate(GateType::H, &[num_data + i])); + } + for i in 0..num_ancilla { + gates.push(gate(GateType::MZ, &[num_data + i])); + } + if round < num_rounds - 1 { + for i in 0..num_ancilla { + gates.push(gate(GateType::PZ, &[num_data + i])); + } + } + } + for q in 0..num_data { + gates.push(gate(GateType::MZ, &[q])); + } + + let expanded = crate::expand::expand_circuit(&gates); + let gate_index = crate::expand::GateIndex::build(&expanded.gates, expanded.num_qubits); + let noise_map = build_noise_map(&expanded.gates, &noise, &gate_index.expansion_gates); + + let init_gates: Vec = (0..num_qubits).map(|q| gate(GateType::PZ, &[q])).collect(); + let stab = + crate::stabilizer::StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + + // Build detectors: round-to-round comparison + let num_detectors = num_ancilla * (num_rounds - 1); + let mut detectors = Vec::new(); + for round in 0..(num_rounds - 1) { + for i in 0..num_ancilla { + let m1 = round * num_ancilla + i; + let m2 = (round + 1) * num_ancilla + i; + let aux1 = expanded.measurement_qubit[m1]; + let aux2 = expanded.measurement_qubit[m2]; + let det_bm = Bm::z(aux1).multiply(&Bm::z(aux2)); + detectors.push(det_bm); + } + } + + // Time windowed (old path) + let start = Instant::now(); + let mut p_windowed = Vec::new(); + for det in &detectors { + p_windowed.push(heisenberg_with_noise_map( + &expanded.gates, + det, + &noise_map, + &stab, + 1e-12, + )); + } + let t_windowed = start.elapsed(); + + // Time sparse (new path) + let start = Instant::now(); + let mut p_sparse = Vec::new(); + for det in &detectors { + p_sparse.push(heisenberg_sparse( + &expanded.gates, + det, + &noise, + &stab, + 1e-12, + &gate_index, + Some(&noise_map), + )); + } + let t_sparse = start.elapsed(); + + // Verify exact match + for (i, (&pw, &ps)) in p_windowed.iter().zip(p_sparse.iter()).enumerate() { + assert!( + (pw - ps).abs() < 1e-12, + "d={d} det{i}: windowed={pw:.15} vs sparse={ps:.15}, diff={:.2e}", + (pw - ps).abs() + ); + } + + let speedup = t_windowed.as_secs_f64() / t_sparse.as_secs_f64(); + eprintln!( + "{d:>4} {num_rounds:>6} {:>8} {:>8} {num_detectors:>6} {:>12.2} {:>12.2} {speedup:>8.1}x", + expanded.gates.len(), + expanded.num_qubits, + t_windowed.as_secs_f64() * 1000.0, + t_sparse.as_secs_f64() * 1000.0 + ); + } + + // --- 2D grid codes (high sparsity at large d) --- + // Each Z-stabilizer checks a plaquette of 4 data qubits using 1 ancilla. + // Detectors are local: each touches only 1 ancilla + 4 data qubits. + // At d=7: 49 data qubits, 24 Z-stab ancillas, ~500+ expanded gates. + // A detector touches ~10 qubits out of ~100+ — high sparsity. + eprintln!("\n--- 2D grid codes (surface-code-like) ---"); + eprintln!( + "{:>4} {:>6} {:>8} {:>8} {:>6} {:>12} {:>12} {:>8}", + "d", "rnds", "gates", "exp_q", "n_det", "windowed_ms", "sparse_ms", "speedup" + ); + + for &(d, num_rounds) in &[(3, 2), (5, 2), (7, 2), (9, 2), (7, 5), (9, 5)] { + // Build a d x d grid with Z-plaquette stabilizers. + // Data qubits: (r, c) for r in 0..d, c in 0..d → index r*d + c + // Z-ancillas: one per plaquette, (d-1)*(d-1) total + let num_data = d * d; + let num_ancilla = (d - 1) * (d - 1); + let num_qubits = num_data + num_ancilla; + let anc_start = num_data; + + let mut gates = Vec::new(); + for q in 0..num_qubits { + gates.push(gate(GateType::PZ, &[q])); + } + + for round in 0..num_rounds { + // Z-stabilizer syndrome: CX(data, anc) for each of 4 data qubits + // Plaquette (r, c) has corners at data qubits: + // (r, c), (r, c+1), (r+1, c), (r+1, c+1) + for r in 0..(d - 1) { + for c in 0..(d - 1) { + let anc = anc_start + r * (d - 1) + c; + let d00 = r * d + c; + let d01 = r * d + c + 1; + let d10 = (r + 1) * d + c; + let d11 = (r + 1) * d + c + 1; + gates.push(gate(GateType::CX, &[d00, anc])); + gates.push(gate(GateType::CX, &[d01, anc])); + gates.push(gate(GateType::CX, &[d10, anc])); + gates.push(gate(GateType::CX, &[d11, anc])); + } + } + for i in 0..num_ancilla { + gates.push(gate(GateType::MZ, &[anc_start + i])); + } + if round < num_rounds - 1 { + for i in 0..num_ancilla { + gates.push(gate(GateType::PZ, &[anc_start + i])); + } + } + } + for q in 0..num_data { + gates.push(gate(GateType::MZ, &[q])); + } + + let expanded = crate::expand::expand_circuit(&gates); + let gate_index = crate::expand::GateIndex::build(&expanded.gates, expanded.num_qubits); + let noise_map = build_noise_map(&expanded.gates, &noise, &gate_index.expansion_gates); + + let init_gates: Vec = (0..num_qubits).map(|q| gate(GateType::PZ, &[q])).collect(); + let stab = + crate::stabilizer::StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + + // Build detectors: round-to-round comparison of each ancilla + let num_detectors = num_ancilla * (num_rounds - 1); + let mut detectors = Vec::new(); + for round in 0..(num_rounds - 1) { + for i in 0..num_ancilla { + let m1 = round * num_ancilla + i; + let m2 = (round + 1) * num_ancilla + i; + let aux1 = expanded.measurement_qubit[m1]; + let aux2 = expanded.measurement_qubit[m2]; + let det_bm = Bm::z(aux1).multiply(&Bm::z(aux2)); + detectors.push(det_bm); + } + } + + // Time windowed + let start = Instant::now(); + let mut p_windowed = Vec::new(); + for det in &detectors { + p_windowed.push(heisenberg_with_noise_map( + &expanded.gates, + det, + &noise_map, + &stab, + 1e-12, + )); + } + let t_windowed = start.elapsed(); + + // Time sparse + let start = Instant::now(); + let mut p_sparse = Vec::new(); + for det in &detectors { + p_sparse.push(heisenberg_sparse( + &expanded.gates, + det, + &noise, + &stab, + 1e-12, + &gate_index, + Some(&noise_map), + )); + } + let t_sparse = start.elapsed(); + + // Verify exact match + for (i, (&pw, &ps)) in p_windowed.iter().zip(p_sparse.iter()).enumerate() { + assert!( + (pw - ps).abs() < 1e-12, + "grid d={d} det{i}: windowed={pw:.15} vs sparse={ps:.15}, diff={:.2e}", + (pw - ps).abs() + ); + } + + let speedup = t_windowed.as_secs_f64() / t_sparse.as_secs_f64(); + eprintln!( + "{d:>4} {num_rounds:>6} {:>8} {:>8} {num_detectors:>6} {:>12.2} {:>12.2} {speedup:>8.1}x", + expanded.gates.len(), + expanded.num_qubits, + t_windowed.as_secs_f64() * 1000.0, + t_sparse.as_secs_f64() * 1000.0 + ); + } + } +} diff --git a/exp/pecos-eeg/src/lib.rs b/exp/pecos-eeg/src/lib.rs new file mode 100644 index 000000000..61bb7c921 --- /dev/null +++ b/exp/pecos-eeg/src/lib.rs @@ -0,0 +1,66 @@ +// 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 EEG crate is experimental physics/math code. Its core routines use +// numerical casts and dense index-based algebra, and the public API is still +// stabilizing. Keep this list narrow and fix ordinary style lints in code. +#![allow( + clippy::cast_possible_truncation, + clippy::cast_possible_wrap, + clippy::cast_precision_loss, + clippy::cast_sign_loss, + clippy::doc_markdown, + clippy::missing_errors_doc, + clippy::missing_panics_doc +)] + +//! Elementary Error Generator (EEG) analysis for coherent noise. +//! +//! Propagates error generators through Clifford circuits and produces +//! detector error probabilities at polynomial cost. Based on: +//! - Miller et al. arXiv:2504.15128 (simulation algorithm) +//! - Hines et al. arXiv:2603.18457 (DEM mapping) +//! +//! # Algorithm +//! +//! 1. Express noise as sparse EEG generators (H, S, C, A types) +//! 2. Propagate each generator forward through Clifford gates +//! 3. Combine via BCH formula (first order = sum) +//! 4. Classify by DEM event (which detectors each Pauli flips) +//! 5. Compute detection event probabilities + +pub mod builder; +pub mod circuit; +pub mod coherent_dem; +pub mod correlation_table; +pub mod dem_generator; +pub mod dem_mapping; +pub mod dem_simulator; +pub mod eeg; +pub mod expand; +pub mod heisenberg; +pub mod noise; +pub mod noise_characterization; +pub mod noise_compression; +pub mod propagate; +pub mod stabilizer; +pub mod strong_sim; + +/// Pauli bitmask type used throughout the EEG crate. +/// Pauli bitmask type used throughout the EEG crate. Uses SmallVec<[u64; 8]> +/// for 512 bits inline (zero allocation up to d=9 surface codes), with +/// automatic heap spillover for larger circuits. +pub type Bm = pecos_core::PauliBitmaskSmall; + +// Re-export key types for convenience +pub use dem_mapping::{BchOrder, EegConfig, HFormula}; +pub use noise::{NoiseInjection, NoiseSpec, UniformNoise}; diff --git a/exp/pecos-eeg/src/noise.rs b/exp/pecos-eeg/src/noise.rs new file mode 100644 index 000000000..57df69c18 --- /dev/null +++ b/exp/pecos-eeg/src/noise.rs @@ -0,0 +1,220 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Noise model specification for EEG analysis. +//! +//! Defines the [`NoiseSpec`] trait for specifying how noise generators are +//! injected at each gate in the circuit. The built-in [`UniformNoise`] +//! applies the same rates to all gates of each type (matching the original +//! `NoiseModel`). Users can implement custom noise for per-gate control. + +use crate::Bm; +use crate::eeg::EegType; +use pecos_core::gate_type::GateType; + +/// A noise generator to inject at a specific point in the circuit. +#[derive(Clone, Debug)] +pub struct NoiseInjection { + /// EEG type of the generator. + pub eeg_type: EegType, + /// Primary Pauli label. + pub label: Bm, + /// Second label for C/A types. + pub label2: Option, + /// Rate (coefficient). + pub rate: f64, +} + +/// Trait for noise models that produce EEG generators at each gate. +/// +/// Implement this to specify arbitrary per-gate noise. The EEG analysis +/// calls `noise_after_gate` for each gate in the expanded circuit, +/// propagates the returned generators to the end, and accumulates them. +/// +/// The built-in [`UniformNoise`] applies the same rates to all gates of +/// each type. For per-gate or per-qubit noise, implement this trait +/// with a custom struct. +pub trait NoiseSpec: Send + Sync { + /// Return noise generators to inject after the gate at `gate_index`. + /// + /// The `qubits` are the qubit indices of the gate. For 2-qubit gates, + /// idle coherent noise is typically injected on both qubits. + /// + /// Return an empty vec for no noise at this gate. + fn noise_after_gate( + &self, + gate_index: usize, + gate_type: GateType, + qubits: &[usize], + ) -> Vec; +} + +/// Uniform noise model: same rates for all gates of each type. +/// +/// This is the original `NoiseModel` wrapped as a `NoiseSpec`. +#[derive(Clone, Debug)] +pub struct UniformNoise { + /// Coherent RZ angle (radians) on both qubits after each 2-qubit gate. + pub idle_rz: f64, + /// Single-qubit depolarizing probability. + pub p1: f64, + /// Two-qubit depolarizing probability. + pub p2: f64, + /// Measurement bit-flip probability. + pub p_meas: f64, + /// Preparation error probability. + pub p_prep: f64, +} + +impl UniformNoise { + #[must_use] + pub fn coherent_only(idle_rz: f64) -> Self { + Self { + idle_rz, + p1: 0.0, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + } + } + + #[must_use] + pub fn depolarizing(p: f64) -> Self { + Self { + idle_rz: 0.0, + p1: p, + p2: p, + p_meas: p, + p_prep: p, + } + } + + #[must_use] + pub fn with_idle_rz(mut self, angle: f64) -> Self { + self.idle_rz = angle; + self + } +} + +impl NoiseSpec for UniformNoise { + fn noise_after_gate( + &self, + _gate_index: usize, + gate_type: GateType, + qubits: &[usize], + ) -> Vec { + let mut injections = Vec::new(); + + match gate_type { + // Two-qubit gates: idle RZ + depolarizing + GateType::CX + | GateType::CZ + | GateType::CY + | GateType::SWAP + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg => { + if self.idle_rz.abs() > 0.0 && qubits.len() >= 2 { + for &q in &qubits[..2] { + injections.push(NoiseInjection { + eeg_type: EegType::H, + label: Bm::z(q), + label2: None, + rate: self.idle_rz / 2.0, + }); + } + } + if self.p2 > 0.0 && qubits.len() >= 2 { + inject_depol_2q(qubits[0], qubits[1], self.p2, &mut injections); + } + } + + // Single-qubit Clifford: depolarizing + GateType::H + | GateType::SZ + | GateType::SZdg + | GateType::SX + | GateType::SXdg + | GateType::SY + | GateType::SYdg + | GateType::X + | GateType::Y + | GateType::Z + if self.p1 > 0.0 && !qubits.is_empty() => + { + inject_depol_1q(qubits[0], self.p1, &mut injections); + } + + // Measurement error + GateType::MZ if self.p_meas > 0.0 => { + for &q in qubits { + injections.push(NoiseInjection { + eeg_type: EegType::S, + label: Bm::x(q), + label2: None, + rate: -self.p_meas, + }); + } + } + + // Preparation error + GateType::PZ if self.p_prep > 0.0 => { + for &q in qubits { + injections.push(NoiseInjection { + eeg_type: EegType::S, + label: Bm::x(q), + label2: None, + rate: -self.p_prep, + }); + } + } + + _ => {} + } + + injections + } +} + +fn inject_depol_1q(q: usize, prob: f64, out: &mut Vec) { + let rate = -prob / 3.0; + for pf in [Bm::x, Bm::y, Bm::z] { + out.push(NoiseInjection { + eeg_type: EegType::S, + label: pf(q), + label2: None, + rate, + }); + } +} + +fn inject_depol_2q(qa: usize, qb: usize, prob: f64, out: &mut Vec) { + let rate = -prob / 15.0; + let pfs = [Bm::x, Bm::y, Bm::z]; + for &pa in &pfs { + out.push(NoiseInjection { + eeg_type: EegType::S, + label: pa(qa), + label2: None, + rate, + }); + out.push(NoiseInjection { + eeg_type: EegType::S, + label: pa(qb), + label2: None, + rate, + }); + for &pb in &pfs { + out.push(NoiseInjection { + eeg_type: EegType::S, + label: pa(qa).multiply(&pb(qb)), + label2: None, + rate, + }); + } + } +} diff --git a/exp/pecos-eeg/src/noise_characterization.rs b/exp/pecos-eeg/src/noise_characterization.rs new file mode 100644 index 000000000..69dcd529a --- /dev/null +++ b/exp/pecos-eeg/src/noise_characterization.rs @@ -0,0 +1,339 @@ +// 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. + +//! Unified noise characterization: correlations + mechanisms + DEM. +//! +//! Outputs use string labels ("D0", "D1", "L0") consistent with Stim format. +//! Includes detector/observable definitions mapping to MeasIds. + +use crate::coherent_dem::build_coherent_dem_exact; +use crate::correlation_table::{CorrelationTableInput, compute_correlation_table}; +use crate::dem_mapping::{DemEntry, DemEvent, Detector, Observable, format_dem}; +use crate::noise::NoiseSpec; +use crate::stabilizer::StabilizerGroup; +use pecos_core::Gate; +use std::fmt::Write as _; + +/// A correlation entry with string labels. +#[derive(Debug, Clone)] +pub struct LabeledCorrelation { + /// Node labels: "D0", "D1", "L0", etc. + pub labels: Vec, + /// Joint probability. + pub probability: f64, +} + +/// A mechanism in the DEM with string labels. +#[derive(Debug, Clone)] +pub struct LabeledMechanism { + /// Detectors this mechanism flips: "D0", "D3", etc. + pub detectors: Vec, + /// Observables this mechanism flips: "L0", etc. + pub observables: Vec, + /// Fitted probability. + pub probability: f64, +} + +/// Definition of a detector or observable in terms of MeasIds. +#[derive(Debug, Clone)] +pub struct NodeDefinition { + /// Label: "D0", "L0", etc. + pub label: String, + /// MeasIds that XOR together to produce this node's value. + pub meas_ids: Vec, + /// Record offsets (negative, relative to end of measurement record). + pub records: Vec, +} + +/// Complete noise characterization. +#[derive(Debug, Clone)] +pub struct NoiseCharacterization { + /// Detector and observable definitions (label -> MeasIds). + pub definitions: Vec, + /// Exact k-body correlations with string labels. + pub correlations: Vec, + /// DEM mechanisms with string labels and fitted probabilities. + pub mechanisms: Vec, + /// Decomposable DEM entries with X/Z component info for MWPM decoders. + pub decomposable_entries: Vec, + /// Maximum correlation order computed. + pub max_order: usize, + /// Number of Heisenberg walks performed. + pub num_walks: usize, +} + +/// Inputs for building a complete EEG noise characterization. +#[derive(Clone, Copy)] +pub struct NoiseCharacterizationInput<'a> { + /// Circuit gates. + pub gates: &'a [Gate], + /// Noise model used for exact Heisenberg correlation targets. + pub noise: &'a dyn NoiseSpec, + /// Optional alternate noise model used for DEM mechanism structure. + pub structure_noise: Option<&'a dyn NoiseSpec>, + /// Detector definitions. + pub detectors: &'a [Detector], + /// Observable definitions. + pub observables: &'a [Observable], + /// Initial stabilizer group. + pub initial_stab: &'a StabilizerGroup, + /// Number of circuit qubits. + pub num_qubits: usize, + /// Maximum correlation order to compute. + pub max_order: usize, + /// Drop probabilities below this threshold. + pub prune_threshold: f64, + /// Detector measurement-record definitions. + pub detector_meas_ids: &'a [(usize, Vec, Vec)], + /// Observable measurement-record definitions. + pub observable_meas_ids: &'a [(usize, Vec, Vec)], +} + +impl NoiseCharacterization { + /// Build from circuit + noise model. + /// + /// `noise` is used for exact Heisenberg correlation targets. + /// `structure_noise` (if provided) is used for DEM mechanism extraction — + /// useful when passing compressed noise for structure while keeping + /// original noise for exact targets. If `None`, uses `noise` for both. + #[must_use] + pub fn build(input: NoiseCharacterizationInput<'_>) -> Self { + let NoiseCharacterizationInput { + gates, + noise, + structure_noise, + detectors, + observables, + initial_stab, + num_qubits, + max_order, + prune_threshold, + detector_meas_ids, + observable_meas_ids, + } = input; + let mechanism_noise = structure_noise.unwrap_or(noise); + + // Correlation table (always uses exact noise) + let table = compute_correlation_table(CorrelationTableInput { + gates, + noise, + detectors, + observables, + initial_stab, + num_qubits, + max_order, + prune_threshold, + }); + + // DEM with fitted probabilities (uses mechanism noise for structure) + let num_dets = detectors.len(); + let mut marginals = vec![0.0_f64; num_dets]; + for det in detectors { + if let Some(&p) = table.rates.get(&vec![det.id]) + && det.id < num_dets + { + marginals[det.id] = p; + } + } + let pairwise: Vec<((usize, usize), f64)> = table + .rates + .iter() + .filter(|(k, _)| k.len() == 2) + .map(|(k, &v)| ((k[0], k[1]), v)) + .collect(); + let gate_index = crate::expand::GateIndex::build(gates, num_qubits); + let dem_entries = build_coherent_dem_exact( + gates, + mechanism_noise, + detectors, + observables, + &gate_index.expansion_gates, + &marginals, + Some(&pairwise), + ); + let decomposable_entries = crate::coherent_dem::build_coherent_dem_exact_decomposable( + gates, + mechanism_noise, + detectors, + observables, + &gate_index.expansion_gates, + &marginals, + Some(&pairwise), + ); + + // Build definitions + let mut definitions = Vec::new(); + for &(id, ref mids, ref recs) in detector_meas_ids { + definitions.push(NodeDefinition { + label: format!("D{id}"), + meas_ids: mids.clone(), + records: recs.clone(), + }); + } + for &(id, ref mids, ref recs) in observable_meas_ids { + definitions.push(NodeDefinition { + label: format!("L{id}"), + meas_ids: mids.clone(), + records: recs.clone(), + }); + } + + // Build labeled correlations from detector rates + let mut correlations = Vec::new(); + for (key, &prob) in &table.rates { + if prob > 1e-15 { + let labels: Vec = key.iter().map(|&d| format!("D{d}")).collect(); + correlations.push(LabeledCorrelation { + labels, + probability: prob, + }); + } + } + // Add observable correlations + for ((det_ids, obs_id), &prob) in &table.observable_rates { + if prob > 1e-15 { + let mut labels: Vec = det_ids.iter().map(|&d| format!("D{d}")).collect(); + labels.push(format!("L{obs_id}")); + correlations.push(LabeledCorrelation { + labels, + probability: prob, + }); + } + } + + // Build labeled mechanisms + let mechanisms: Vec = dem_entries + .iter() + .filter(|e| e.probability > 1e-15) + .map(|e| LabeledMechanism { + detectors: e.event.detectors.iter().map(|&d| format!("D{d}")).collect(), + observables: e + .event + .observables + .iter() + .map(|&o| format!("L{o}")) + .collect(), + probability: e.probability, + }) + .collect(); + + NoiseCharacterization { + definitions, + correlations, + mechanisms, + decomposable_entries, + max_order: table.max_order, + num_walks: table.num_walks, + } + } + + /// Output as Stim DEM string. + #[must_use] + pub fn to_dem_string(&self) -> String { + let entries: Vec = self + .mechanisms + .iter() + .map(|m| { + let dets: Vec = m + .detectors + .iter() + .map(|s| s[1..].parse().unwrap_or(0)) + .collect(); + let obs: Vec = m + .observables + .iter() + .map(|s| s[1..].parse().unwrap_or(0)) + .collect(); + DemEntry { + event: DemEvent { + detectors: dets.into_iter().collect(), + observables: obs.into_iter().collect(), + }, + probability: m.probability, + } + }) + .collect(); + format_dem(&entries) + } + + /// Output as decomposed (graphlike) DEM string for MWPM decoders. + /// + /// Uses X/Z Pauli-aware decomposition from the backward mechanism + /// extraction. Hyperedges are split into X ^ Z components. + #[must_use] + pub fn to_dem_string_decomposed(&self) -> String { + crate::dem_mapping::format_dem_decomposed(&self.decomposable_entries) + } + + /// Serialize to JSON. + #[must_use] + pub fn to_json(&self) -> String { + let mut j = String::from("{\n"); + + let _ = writeln!(j, " \"max_order\": {},", self.max_order); + let _ = writeln!(j, " \"num_walks\": {},", self.num_walks); + + // Definitions + j.push_str(" \"definitions\": [\n"); + for (i, def) in self.definitions.iter().enumerate() { + let _ = write!( + j, + " {{\"label\": \"{}\", \"meas_ids\": {:?}, \"records\": {:?}}}", + def.label, def.meas_ids, def.records + ); + if i + 1 < self.definitions.len() { + j.push(','); + } + j.push('\n'); + } + j.push_str(" ],\n"); + + // Correlations + j.push_str(" \"correlations\": [\n"); + for (i, c) in self.correlations.iter().enumerate() { + let _ = write!( + j, + " {{\"nodes\": {:?}, \"probability\": {:.10e}}}", + c.labels, c.probability + ); + if i + 1 < self.correlations.len() { + j.push(','); + } + j.push('\n'); + } + j.push_str(" ],\n"); + + // Mechanisms + j.push_str(" \"mechanisms\": [\n"); + for (i, m) in self.mechanisms.iter().enumerate() { + let mut nodes: Vec<&str> = m + .detectors + .iter() + .map(std::string::String::as_str) + .collect(); + nodes.extend(m.observables.iter().map(std::string::String::as_str)); + let _ = write!( + j, + " {{\"nodes\": {:?}, \"probability\": {:.10e}}}", + nodes, m.probability + ); + if i + 1 < self.mechanisms.len() { + j.push(','); + } + j.push('\n'); + } + j.push_str(" ]\n"); + + j.push('}'); + j + } +} diff --git a/exp/pecos-eeg/src/noise_compression.rs b/exp/pecos-eeg/src/noise_compression.rs new file mode 100644 index 000000000..a2f09c2e7 --- /dev/null +++ b/exp/pecos-eeg/src/noise_compression.rs @@ -0,0 +1,354 @@ +// 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. + +//! Round-boundary noise compression. +//! +//! Propagates mid-round fault locations to round boundaries, producing +//! effective noise sources with accumulated probabilities/amplitudes. +//! +//! For stochastic Pauli noise: exact (Paulis compose deterministically). +//! For coherent noise: accumulates within-round angles exactly. +//! +//! This dramatically reduces the number of noise sources: +//! ~60 mid-round faults per round → ~17 boundary faults (9 data + 8 meas). + +use crate::Bm; +use crate::eeg::EegType; +use crate::noise::{NoiseInjection, NoiseSpec}; +use pecos_core::Gate; +use pecos_core::gate_type::GateType; +use smallvec::SmallVec; +use std::collections::BTreeMap; + +/// An effective noise source at a round boundary. +#[derive(Debug, Clone)] +pub struct BoundaryNoise { + /// The effective Pauli label at the boundary. + pub label: Bm, + /// EEG type (H or S). + pub eeg_type: EegType, + /// Accumulated rate (S-type) or amplitude (H-type). + pub value: f64, + /// Gate index of the boundary (for position tracking). + pub boundary_gate: usize, +} + +/// Result of noise compression. +pub struct CompressedNoise { + /// Effective noise sources at round boundaries. + pub boundary_sources: Vec, + /// Measurement noise (kept as-is, not compressed). + pub measurement_sources: Vec<(usize, NoiseInjection)>, + /// Preparation noise (kept as-is). + pub preparation_sources: Vec<(usize, NoiseInjection)>, + /// Number of original noise sources before compression. + pub original_count: usize, + /// Number of compressed sources. + pub compressed_count: usize, +} + +/// Compress mid-round noise to round boundaries. +/// +/// Identifies rounds (PZ/QAlloc → gates → MZ), propagates each +/// mid-round noise source's Pauli label forward through remaining +/// gates to the next boundary, and accumulates. +/// +/// Gate noise (p1, p2) is compressed. Measurement (p_meas) and +/// preparation (p_prep) noise is kept at its original position. +pub fn compress_noise_to_boundaries( + gates: &[Gate], + noise: &dyn NoiseSpec, + expansion_gates: &[bool], +) -> CompressedNoise { + let n_gates = gates.len(); + let max_qubit = gates + .iter() + .flat_map(|g| g.qubits.iter()) + .map(pecos_core::QubitId::index) + .max() + .unwrap_or(0); + + // Step 1: Collect all noise sources + let mut all_noise: Vec<(usize, NoiseInjection)> = Vec::new(); + for (gate_idx, gate) in gates.iter().enumerate() { + if gate_idx < expansion_gates.len() && expansion_gates[gate_idx] { + continue; + } + let qubits: SmallVec<[usize; 4]> = + gate.qubits.iter().map(pecos_core::QubitId::index).collect(); + let injections = noise.noise_after_gate(gate_idx, gate.gate_type, &qubits); + for inj in injections { + all_noise.push((gate_idx, inj)); + } + } + + let original_count = all_noise.len(); + + // Step 2: Identify round boundaries. + // A boundary is an MZ gate (end of round) or PZ/QAlloc (start of round). + // We propagate gate noise forward to the NEXT MZ on the same qubit. + let mut measurement_sources = Vec::new(); + let mut preparation_sources = Vec::new(); + let mut gate_noise: Vec<(usize, NoiseInjection)> = Vec::new(); + + for (gate_idx, inj) in &all_noise { + let gate = &gates[*gate_idx]; + match gate.gate_type { + GateType::MZ | GateType::MeasureFree => { + measurement_sources.push((*gate_idx, inj.clone())); + } + GateType::PZ | GateType::QAlloc => { + preparation_sources.push((*gate_idx, inj.clone())); + } + _ => { + gate_noise.push((*gate_idx, inj.clone())); + } + } + } + + // Step 3: For each gate noise source, propagate its label forward + // through subsequent gates until we hit a round boundary (MZ or PZ). + // Group by (boundary_gate, effective_label, eeg_type). + let mut boundary_groups: BTreeMap<(usize, Bm, EegType), f64> = BTreeMap::new(); + + for (gate_idx, inj) in &gate_noise { + let mut label = inj.label.clone(); + + // Propagate forward through subsequent gates until we hit a boundary. + // The effective noise lives just BEFORE the boundary gate, so we + // inject it "after" the last non-boundary gate before it. + let mut inject_at = *gate_idx; // default: stay at original position + for g in (*gate_idx + 1)..n_gates { + match gates[g].gate_type { + GateType::MZ | GateType::MeasureFree | GateType::PZ | GateType::QAlloc => { + let noise_qubits: Vec = label_qubits(&label, max_qubit); + let boundary_qubits: Vec = gates[g] + .qubits + .iter() + .map(pecos_core::QubitId::index) + .collect(); + if noise_qubits.iter().any(|q| boundary_qubits.contains(q)) { + // Inject at the gate just before the boundary + // (inject_at was set to g-1 by the last non-boundary gate) + break; + } + } + _ => { + // Propagate the label forward through this gate + forward_conjugate_label(&mut label, &gates[g]); + // Only update inject_at for non-expansion gates. + // Expansion gates are invisible to the noise map — + // injecting there would be silently dropped. + if !(g < expansion_gates.len() && expansion_gates[g]) { + inject_at = g; + } + } + } + } + + let key = (inject_at, label.clone(), inj.eeg_type); + *boundary_groups.entry(key).or_insert(0.0) += inj.rate; + } + + // Step 4: Convert groups to boundary noise sources + let boundary_sources: Vec = boundary_groups + .into_iter() + .filter(|(_, value)| value.abs() > 1e-20) + .map(|((boundary_gate, label, eeg_type), value)| BoundaryNoise { + label, + eeg_type, + value, + boundary_gate, + }) + .collect(); + + let compressed_count = + boundary_sources.len() + measurement_sources.len() + preparation_sources.len(); + + CompressedNoise { + boundary_sources, + measurement_sources, + preparation_sources, + original_count, + compressed_count, + } +} + +/// Extract qubits that a Pauli label acts on (up to max_qubit). +fn label_qubits(label: &Bm, max_qubit: usize) -> Vec { + let mut qubits = Vec::new(); + for q in 0..=max_qubit { + if label.has_x(q) || label.has_z(q) { + qubits.push(q); + } + } + qubits +} + +/// Forward-conjugate a Pauli label through a gate (Schrödinger picture). +/// +/// This is the FORWARD direction: P → U P U†. +/// For non-self-adjoint gates, we use the forward conjugation directly +/// (not the adjoint swap used in backward walks). +fn forward_conjugate_label(label: &mut Bm, gate: &Gate) { + use crate::heisenberg::{SparsePauli, sparse_conjugate}; + + match gate.gate_type { + GateType::PZ + | GateType::QAlloc + | GateType::QFree + | GateType::MZ + | GateType::MeasureFree + | GateType::MeasureLeaked + | GateType::I + | GateType::Idle => return, + _ => {} + } + + // sparse_conjugate uses backward (Heisenberg) convention. + // For forward propagation of a Pauli label, we need U P U†. + // For self-adjoint gates: same as backward. + // For non-self-adjoint: we need to swap the gate to its adjoint + // before calling sparse_conjugate (which already swaps for backward). + // Two swaps cancel → just call sparse_conjugate on the adjoint gate. + // + // Simpler: for forward, swap S↔Sdg BEFORE calling sparse_conjugate + // (which swaps again for backward), giving net: forward conjugation. + // + // Actually, sparse_conjugate applies backward convention (swaps non-self-adjoint). + // For forward conjugation, we need the opposite swap. + // Forward of SZ: SZ P SZdg → same as backward of SZdg. + // So forward_conjugate(P, SZ) = sparse_conjugate(P, SZdg). + // + // For self-adjoint gates: no difference. + // For non-self-adjoint: pass the adjoint gate type. + + let adjoint_type = match gate.gate_type { + GateType::SZ => GateType::SZdg, + GateType::SZdg => GateType::SZ, + GateType::SX => GateType::SXdg, + GateType::SXdg => GateType::SX, + GateType::SY => GateType::SYdg, + GateType::SYdg => GateType::SY, + GateType::SZZ => GateType::SZZdg, + GateType::SZZdg => GateType::SZZ, + GateType::SXX => GateType::SXXdg, + GateType::SXXdg => GateType::SXX, + GateType::SYY => GateType::SYYdg, + GateType::SYYdg => GateType::SYY, + other => other, // self-adjoint + }; + + // Build a temporary gate with the adjoint type + let adj_gate = Gate { + gate_type: adjoint_type, + qubits: gate.qubits.clone(), + angles: gate.angles.clone(), + params: gate.params.clone(), + meas_ids: gate.meas_ids.clone(), + channel: None, + }; + + let mut sp = SparsePauli::from_bm(label); + let _sign = sparse_conjugate(&mut sp, &adj_gate); + *label = sp.to_bm(); +} + +/// A `NoiseSpec` adapter that returns compressed boundary noise. +/// +/// Call `noise_after_gate()` on each gate just like the original noise model, +/// but mid-round gate noise is empty — all accumulated at boundaries. +pub struct CompressedNoiseSpec { + /// Gate index → noise injections at that gate. + gate_noise: BTreeMap>, +} + +impl CompressedNoiseSpec { + /// Build from compressed noise result. + #[must_use] + pub fn from_compressed(compressed: &CompressedNoise) -> Self { + let mut gate_noise: BTreeMap> = BTreeMap::new(); + + // Boundary sources → noise at boundary gate + for bn in &compressed.boundary_sources { + gate_noise + .entry(bn.boundary_gate) + .or_default() + .push(NoiseInjection { + eeg_type: bn.eeg_type, + label: bn.label.clone(), + label2: None, + rate: bn.value, + }); + } + + // Measurement and prep sources stay at original positions + for (gate_idx, inj) in &compressed.measurement_sources { + gate_noise.entry(*gate_idx).or_default().push(inj.clone()); + } + for (gate_idx, inj) in &compressed.preparation_sources { + gate_noise.entry(*gate_idx).or_default().push(inj.clone()); + } + + Self { gate_noise } + } +} + +impl NoiseSpec for CompressedNoiseSpec { + fn noise_after_gate( + &self, + gate_index: usize, + _gate_type: GateType, + _qubits: &[usize], + ) -> Vec { + self.gate_noise + .get(&gate_index) + .cloned() + .unwrap_or_default() + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::noise::UniformNoise; + + #[test] + fn test_compression_reduces_count() { + // Simple circuit: PZ(0,1), CX(0,1), H(1), CX(0,1), MZ(0,1) + let gates = vec![ + crate::expand::make_gate(GateType::PZ, &[0]), + crate::expand::make_gate(GateType::PZ, &[1]), + crate::expand::make_gate(GateType::CX, &[0, 1]), + crate::expand::make_gate(GateType::H, &[1]), + crate::expand::make_gate(GateType::CX, &[0, 1]), + crate::expand::make_gate(GateType::MZ, &[0]), + crate::expand::make_gate(GateType::MZ, &[1]), + ]; + let noise = UniformNoise { + idle_rz: 0.0, + p1: 0.001, + p2: 0.01, + p_meas: 0.01, + p_prep: 0.01, + }; + let expansion = vec![false; gates.len()]; + + let result = compress_noise_to_boundaries(&gates, &noise, &expansion); + assert!( + result.compressed_count < result.original_count, + "compressed {} should be < original {}", + result.compressed_count, + result.original_count + ); + } +} diff --git a/exp/pecos-eeg/src/propagate.rs b/exp/pecos-eeg/src/propagate.rs new file mode 100644 index 000000000..23c70ef17 --- /dev/null +++ b/exp/pecos-eeg/src/propagate.rs @@ -0,0 +1,11 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Re-export Clifford conjugation from pecos-core. + +pub use pecos_core::pauli::pauli_bitmask::{ + conjugate_cx, conjugate_cy, conjugate_cz, conjugate_h, conjugate_swap, conjugate_sx, + conjugate_sxdg, conjugate_sy, conjugate_sydg, conjugate_sz, conjugate_szdg, conjugate_x, + conjugate_y, conjugate_z, +}; diff --git a/exp/pecos-eeg/src/stabilizer.rs b/exp/pecos-eeg/src/stabilizer.rs new file mode 100644 index 000000000..e4829fe32 --- /dev/null +++ b/exp/pecos-eeg/src/stabilizer.rs @@ -0,0 +1,247 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Track the stabilizer group of the noiseless output state using SparseStab. + +use crate::Bm; +use pecos_core::gate_type::GateType; +use pecos_core::pauli::pauli_bitmask::BitmaskStorage; +use pecos_core::{Gate, QuarterPhase, QubitId}; +use pecos_simulators::{CliffordGateable, SparseStab}; + +/// The stabilizer group, tracked via SparseStab. +/// +/// Keeps the full SparseStab (stabilizers + destabilizers) so we can +/// determine the exact sign (+1 or -1) of stabilizer group elements. +pub struct StabilizerGroup { + sim: SparseStab, +} + +impl StabilizerGroup { + /// Run the noiseless circuit on SparseStab. + #[must_use] + pub fn from_circuit(gates: &[Gate], num_qubits: usize) -> Self { + let mut sim = SparseStab::with_seed(num_qubits, 0); + + for gate in gates { + let qubits: Vec = gate.qubits.iter().copied().collect(); + if qubits.is_empty() { + continue; + } + + match gate.gate_type { + GateType::PZ | GateType::QAlloc => { + for &q in &qubits { + sim.pz(&[q]); + } + } + GateType::H => { + sim.h(&qubits); + } + GateType::SZ => { + sim.sz(&qubits); + } + GateType::SZdg => { + sim.szdg(&qubits); + } + GateType::SX => { + sim.sx(&qubits); + } + GateType::SXdg => { + sim.sxdg(&qubits); + } + GateType::SY => { + sim.sy(&qubits); + } + GateType::SYdg => { + sim.sydg(&qubits); + } + GateType::X => { + sim.x(&qubits); + } + GateType::Y => { + sim.y(&qubits); + } + GateType::Z => { + sim.z(&qubits); + } + GateType::CX if qubits.len() >= 2 => { + sim.cx(&[(qubits[0], qubits[1])]); + } + GateType::CY if qubits.len() >= 2 => { + sim.cy(&[(qubits[0], qubits[1])]); + } + GateType::CZ if qubits.len() >= 2 => { + sim.cz(&[(qubits[0], qubits[1])]); + } + GateType::SWAP if qubits.len() >= 2 => { + sim.swap(&[(qubits[0], qubits[1])]); + } + GateType::MZ => { + sim.mz(&qubits); + } + _ => {} + } + } + + Self { sim } + } + + /// Check if Pauli P is in the stabilizer group and return its sign. + /// + /// Returns: + /// - `Some(true)` if P is a +1 stabilizer + /// - `Some(false)` if P is a -1 stabilizer (anti-stabilizer) + /// - `None` if P is not in the stabilizer group + #[must_use] + pub fn is_stabilizer(&self, p: &Bm) -> Option { + if p.is_identity() { + return Some(true); + } + + let mut x_positions = Vec::new(); + let mut z_positions = Vec::new(); + let mut num_ys = 0usize; + + let max_q = match (p.x_bits.highest_set_bit(), p.z_bits.highest_set_bit()) { + (None, None) => return Some(true), + (Some(a), None) | (None, Some(a)) => a + 1, + (Some(a), Some(b)) => a.max(b) + 1, + }; + + for q in 0..max_q { + let has_x = p.has_x(q); + let has_z = p.has_z(q); + if has_x { + x_positions.push(q); + } + if has_z { + z_positions.push(q); + } + if has_x && has_z { + num_ys += 1; + } + } + + let stabs = self.sim.stabs(); + let destabs = self.sim.destabs(); + let phase = stabs.find_pauli_sign(destabs, x_positions, z_positions, num_ys)?; + + match phase { + QuarterPhase::PlusOne => Some(true), + QuarterPhase::MinusOne => Some(false), + _ => None, + } + } +} + +#[cfg(test)] +mod tests { + use super::*; + use pecos_core::{GateAngles, GateParams}; + + fn gate(gt: GateType, qubits: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qubits.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } + } + + #[test] + fn test_identity_circuit() { + let stabs = StabilizerGroup::from_circuit(&[], 1); + assert_eq!(stabs.is_stabilizer(&Bm::z(0)), Some(true)); + assert_eq!(stabs.is_stabilizer(&Bm::x(0)), None); + } + + #[test] + fn test_h_circuit() { + let gates = vec![gate(GateType::H, &[0])]; + let stabs = StabilizerGroup::from_circuit(&gates, 1); + assert_eq!(stabs.is_stabilizer(&Bm::x(0)), Some(true)); + assert_eq!(stabs.is_stabilizer(&Bm::z(0)), None); + } + + #[test] + fn test_bell_state() { + let gates = vec![gate(GateType::H, &[0]), gate(GateType::CX, &[0, 1])]; + let stabs = StabilizerGroup::from_circuit(&gates, 2); + assert_eq!( + stabs.is_stabilizer(&Bm::x(0).multiply(&Bm::x(1))), + Some(true) + ); + assert_eq!( + stabs.is_stabilizer(&Bm::z(0).multiply(&Bm::z(1))), + Some(true) + ); + } + + #[test] + fn test_x0x1_after_syndrome_extraction() { + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[4]), + gate(GateType::H, &[4]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[4, 0]), + gate(GateType::H, &[4]), + gate(GateType::MZ, &[4]), + gate(GateType::PZ, &[4]), + ]; + let stabs = StabilizerGroup::from_circuit(&gates, 5); + + let x0x1 = Bm::x(0).multiply(&Bm::x(1)); + assert_eq!( + stabs.is_stabilizer(&x0x1), + Some(true), + "X0*X1 should be stabilizer after syndrome extraction with MZ projection" + ); + } + + #[test] + fn test_anti_stabilizer() { + // After PZ, Z is +1 stabilizer. -Z should be anti-stabilizer. + // But -Z isn't a Pauli label in our system (no phase in Bm). + // Instead, apply X to flip the state to |1>, then Z has eigenvalue -1. + let gates = vec![gate(GateType::X, &[0])]; + let stabs = StabilizerGroup::from_circuit(&gates, 1); + // Initial state is |0>, X takes it to |1>. + // Z|1> = -|1>, so Z is a -1 stabilizer. + assert_eq!( + stabs.is_stabilizer(&Bm::z(0)), + Some(false), + "Z should be -1 stabilizer for |1> state" + ); + } + + #[test] + fn test_sign_bell_minus() { + // |Phi-> = (|00> - |11>)/sqrt(2) = CX H X |00> + // X flips to |10>, H gives |−0>, CX gives |Phi-> + // Stabilizers: -XX, +ZZ + let gates = vec![ + gate(GateType::X, &[0]), + gate(GateType::H, &[0]), + gate(GateType::CX, &[0, 1]), + ]; + let stabs = StabilizerGroup::from_circuit(&gates, 2); + // XX should be -1 stabilizer (the minus Bell state) + assert_eq!( + stabs.is_stabilizer(&Bm::x(0).multiply(&Bm::x(1))), + Some(false), + "XX should be -1 stabilizer for |Phi->" + ); + // ZZ should be +1 stabilizer + assert_eq!( + stabs.is_stabilizer(&Bm::z(0).multiply(&Bm::z(1))), + Some(true), + "ZZ should be +1 stabilizer for |Phi->" + ); + } +} diff --git a/exp/pecos-eeg/src/strong_sim.rs b/exp/pecos-eeg/src/strong_sim.rs new file mode 100644 index 000000000..13795e3d0 --- /dev/null +++ b/exp/pecos-eeg/src/strong_sim.rs @@ -0,0 +1,655 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Approximate strong simulation using EEG generators. +//! +//! Computes approximate outcome probabilities p̃_x for arbitrary bit strings x, +//! using the first-order Taylor expansion from Miller et al. (Eq. 17): +//! +//! p̃_x = p_x + (1/2^ζ) Σ_G α(ψ,G,x) ε_G + O(ε²) +//! +//! where α(ψ,G,x) = 2^ζ Tr(|x⟩⟨x| G[|ψ⟩⟨ψ|]) encodes how each generator +//! affects the probability of outcome x. +//! +//! At first order (l=1), only S-type generators contribute (H-type contributes +//! at second order). The S-type α is: +//! α(x, S_P, ψ) = [x⊕a ∈ support(ψ)] - [x ∈ support(ψ)] +//! where a is the X-component of P. + +use crate::Bm; +use crate::circuit::PropagatedEeg; +use crate::eeg::EegType; +use pecos_core::pauli::pauli_bitmask::BitmaskStorage; + +/// Result of approximate strong simulation for a specific outcome. +#[derive(Clone, Debug)] +pub struct OutcomeProbability { + /// Noiseless probability p_x = |⟨x|ψ⟩|². + pub noiseless: f64, + /// First-order S-type correction. + pub s_correction: f64, + /// Second-order H·H correction (via C-type α). + pub h_correction: f64, + /// Total approximate probability: noiseless + corrections. + pub total: f64, +} + +/// Compute the approximate probability of outcome x at first order. +/// +/// The outcome is a bit string (true = |1⟩, false = |0⟩) for each measured qubit. +/// The generators should be propagated to the end of the expanded circuit. +/// +/// At first order, only S-type generators contribute: +/// α(x, S_P, ψ) = [x⊕a ∈ support] - [x ∈ support] +/// where a is the X-component of P and "support" is the set of computational +/// basis states with nonzero amplitude in |ψ⟩. +/// +/// For a stabilizer state |ψ⟩ on n qubits: x is in the support iff +/// all stabilizer generators have eigenvalue +1 on |x⟩. +/// +/// # Arguments +/// * `generators` - Propagated EEG generators at end of circuit +/// * `outcome` - Bit string x (one bool per qubit) +/// * `stabilizers` - Stabilizer generators of |ψ⟩ as Bm +/// +/// # Limitations +/// Currently computes first-order (S-type) corrections only. H-type +/// corrections require second-order computation with phase tracking. +#[must_use] +pub fn outcome_probability( + generators: &[PropagatedEeg], + outcome: &[bool], + stabilizers: &[Bm], +) -> OutcomeProbability { + let n = outcome.len(); + + // Check if x is in the support of |ψ⟩. + // x ∈ support iff ⟨x|S|x⟩ = +1 for all stabilizer generators S. + let x_in_support = is_in_support(outcome, stabilizers); + + // Noiseless probability: 1/2^ζ if in support, 0 otherwise. + // ζ = n - rank(stabilizer group restricted to Z-diagonal). + // For a pure state: ζ = 0 (deterministic), p_x = 0 or 1. + // For a projected state: ζ > 0, p_x = 1/2^ζ. + let zeta = compute_zeta(n, stabilizers); + let noiseless = if x_in_support { + 1.0 / (1u64 << zeta) as f64 + } else { + 0.0 + }; + + // First-order S-type corrections: α(x, S_P, ψ) = [x⊕a ∈ support] - [x ∈ support] + let mut s_correction = 0.0; + let scale = if zeta > 0 { + 1.0 / (1u64 << zeta) as f64 + } else { + 1.0 + }; + + for g in generators { + if g.eeg_type != EegType::S { + continue; + } + + let x_flipped = flip_outcome(outcome, &g.label); + let flipped_in_support = is_in_support(&x_flipped, stabilizers); + + let alpha = + (if flipped_in_support { 1.0 } else { 0.0 }) - (if x_in_support { 1.0 } else { 0.0 }); + + s_correction += scale * alpha * g.coeff; + } + + // Second-order H·H corrections using α(x, C_{P,P'}, ψ). + // (1/2) Σ_{P,P'} h_P h_{P'} α(x, C_{P,P'}, ψ) + // + // For commuting P,P': α(C) = 2 Re(Φ(P,P')) - 2 Re(Φ(PP',I)) + // For anticommuting P,P': α(C) = 2 Re(Φ(P,P')) (since {P,P'}=0 → Φ(PP',I) cancels) + // + // Extract stabilizer phases for Φ computation. + let stab_phases: Vec = stabilizers + .iter() + .map(|_| false) // Default: all +1 stabilizers (sign info not available from Bm) + .collect(); + + let h_gens: Vec<_> = generators + .iter() + .filter(|g| g.eeg_type == EegType::H) + .collect(); + + let mut h_correction = 0.0; + for j in 0..h_gens.len() { + for k in 0..h_gens.len() { + let h_j = h_gens[j].coeff; + let h_k = h_gens[k].coeff; + let p = &h_gens[j].label; + let q = &h_gens[k].label; + + // α(x, C_{P,Q}, ψ) for commuting P,Q: + // = 2 Re(Φ(P,Q)) - 2 Re(Φ(PQ,I)) + let phi_pq = compute_phi(p, q, outcome, stabilizers, &stab_phases); + let pq = p.multiply(q); + let identity = Bm::default(); + let phi_pq_i = compute_phi(&pq, &identity, outcome, stabilizers, &stab_phases); + + let alpha = if p.commutes_with(q) { + 2.0 * phi_pq.0 - 2.0 * phi_pq_i.0 + } else { + // Anticommuting: α(C) = 2 Re(Φ(P,Q)) + 2.0 * phi_pq.0 + }; + + // (1/2) h_j h_k α + h_correction += scale * 0.5 * h_j * h_k * alpha; + } + } + + // First-order C and A type corrections (if any exist directly in generators). + let mut ca_correction = 0.0; + for g in generators { + match g.eeg_type { + EegType::C => { + if let Some(ref q_label) = g.label2 { + // α(x, C_{P,Q}) = 2 Re(Φ(P,Q)) - Re(Φ(PQ,I) + Φ(QP,I)) + let phi_pq = compute_phi(&g.label, q_label, outcome, stabilizers, &stab_phases); + let pq = g.label.multiply(q_label); + let qp = q_label.multiply(&g.label); + let phi_pq_i = + compute_phi(&pq, &Bm::default(), outcome, stabilizers, &stab_phases); + let phi_qp_i = + compute_phi(&qp, &Bm::default(), outcome, stabilizers, &stab_phases); + let alpha = 2.0 * phi_pq.0 - (phi_pq_i.0 + phi_qp_i.0); + ca_correction += scale * g.coeff * alpha; + } + } + EegType::A => { + if let Some(ref q_label) = g.label2 { + // α(x, A_{P,Q}) = 2 Im(Φ(Q,P)) + Im(Φ(QP,I) - Φ(PQ,I)) + let phi_qp = compute_phi(q_label, &g.label, outcome, stabilizers, &stab_phases); + let qp = q_label.multiply(&g.label); + let pq = g.label.multiply(q_label); + let phi_qp_i = + compute_phi(&qp, &Bm::default(), outcome, stabilizers, &stab_phases); + let phi_pq_i = + compute_phi(&pq, &Bm::default(), outcome, stabilizers, &stab_phases); + let alpha = 2.0 * phi_qp.1 + (phi_qp_i.1 - phi_pq_i.1); + ca_correction += scale * g.coeff * alpha; + } + } + _ => {} + } + } + + let total = (noiseless + s_correction + h_correction + ca_correction).clamp(0.0, 1.0); + + OutcomeProbability { + noiseless, + s_correction, + h_correction, + total, + } +} + +/// Compute Φ_{ψ,x}(P,Q) = 2^ζ ⟨x|P|ψ⟩⟨ψ|Q|x⟩ for stabilizer states. +/// +/// Returns a complex number as (real, imag). Result is in {0, ±1, ±i}. +/// +/// Uses: Φ(P,Q) = phase(P·S_0·Q) · (-1)^{z_{PS_0Q}·x} when conditions met. +/// S_0 is any stabilizer with x_part = x_P ⊕ x_Q. +fn compute_phi( + p: &Bm, + q: &Bm, + outcome: &[bool], + stabilizers: &[Bm], + stabilizer_phases: &[bool], // true = -1 sign (minus stabilizer) +) -> (f64, f64) { + // ⟨x|P|ψ⟩ is nonzero iff x⊕a_P is in the support of |ψ⟩. + // ⟨ψ|Q|x⟩ is nonzero iff x⊕a_Q is in the support. + // Both must hold for Φ to be nonzero. + let x_flip_p = flip_outcome(outcome, p); + if !is_in_support(&x_flip_p, stabilizers) { + return (0.0, 0.0); + } + let x_flip_q = flip_outcome(outcome, q); + if !is_in_support(&x_flip_q, stabilizers) { + return (0.0, 0.0); + } + + // Target X-pattern for S_0: x_P ⊕ x_Q + let target_x = p.multiply(q); // product has x_bits = p.x XOR q.x (and z, but we only use x) + + // Find a subset of generators whose X-parts XOR to target_x.x_bits + let n = stabilizers.len(); + + // Work with full Bm for GF2 ops (only X-part matters) + let mut row_x: Vec = stabilizers + .iter() + .map(|s| Bm { + x_bits: s.x_bits.clone(), + z_bits: smallvec::SmallVec::default(), + }) + .collect(); + + let mut selected = vec![false; n]; + let mut target = Bm { + x_bits: target_x.x_bits.clone(), + z_bits: smallvec::SmallVec::default(), + }; + + // GF(2) greedy elimination + for bit in 0..outcome.len() { + if !target.x_bits.get_bit(bit) { + continue; + } + let found = row_x + .iter() + .enumerate() + .find(|(_, r)| r.x_bits.get_bit(bit)); + if let Some((row_idx, _)) = found { + // Find the original stabilizer index for this row + // (rows may have been XOR-modified but indices track the original) + selected[row_idx] = true; + let pivot = row_x[row_idx].clone(); + target = target.multiply(&pivot); + for (r, row) in row_x.iter_mut().enumerate().take(n) { + if r != row_idx && row.x_bits.get_bit(bit) { + let p_clone = pivot.clone(); + *row = row.multiply(&p_clone); + } + } + } else { + return (0.0, 0.0); + } + } + + if !target.is_identity() { + return (0.0, 0.0); + } + + // Build S_0 = product of selected generators + let mut s0 = Bm::default(); + let mut s0_phase: u8 = 0; // i^{s0_phase} + let mut s0_sign_minus = false; + + for i in 0..n { + if selected[i] { + let (prod, phase) = s0.multiply_with_phase(&stabilizers[i]); + s0 = prod; + s0_phase = (s0_phase + phase) % 4; + if stabilizer_phases[i] { + s0_sign_minus = !s0_sign_minus; + } + } + } + + // S_0 has sign (-1)^{s0_sign_minus} · i^{s0_phase} + // Compute PSQ = P · S_0 · Q + let (ps, phase_ps) = p.multiply_with_phase(&s0); + let (psq, phase_psq_part) = ps.multiply_with_phase(q); + let total_phase = (phase_ps + phase_psq_part + s0_phase) % 4; + + // PSQ should be diagonal (x_part = 0) + if !psq.x_bits.is_zero() { + return (0.0, 0.0); // Shouldn't happen if solution found correctly + } + + // Compute (-1)^{z_{PSQ} · x} + let mut dot = 0u32; + for (i, &bit) in outcome.iter().enumerate() { + if bit && psq.z_bits.get_bit(i) { + dot += 1; + } + } + let z_sign: f64 = if dot.is_multiple_of(2) { 1.0 } else { -1.0 }; + + // Total sign from S_0 being a (-1)^{sign} stabilizer + let stab_sign: f64 = if s0_sign_minus { -1.0 } else { 1.0 }; + + // Φ = i^{total_phase} · stab_sign · z_sign + let (re, im) = match total_phase { + 0 => (1.0, 0.0), + 1 => (0.0, 1.0), + 2 => (-1.0, 0.0), + 3 => (0.0, -1.0), + _ => unreachable!(), + }; + + (re * stab_sign * z_sign, im * stab_sign * z_sign) +} + +/// Check if outcome x is in the support of the stabilizer state. +/// +/// x ∈ support iff for every stabilizer generator S, ⟨x|S|x⟩ = +1. +/// For S = phase · X^a Z^b: ⟨x|S|x⟩ = phase · δ_{a,0} · (-1)^{b·x} +/// (since X flips bits, ⟨x|X^a|x⟩ = 0 unless a=0). +/// +/// Wait: that's only for diagonal stabilizers. For non-diagonal (X or Y +/// components), ⟨x|S|x⟩ = 0 ≠ +1, so x is not in the support if any +/// stabilizer has X component. But stabilizer states CAN have X-type +/// stabilizers and still have x in the support. +/// +/// The correct check: x is in the support iff for all Z-type stabilizers +/// (those with no X component), the Z eigenvalue matches. +fn is_in_support(outcome: &[bool], stabilizers: &[Bm]) -> bool { + for stab in stabilizers { + // Only Z-type stabilizers constrain the support + if !stab.x_bits.is_zero() { + continue; // Has X component — doesn't constrain Z-basis support + } + + // Z-type stabilizer: eigenvalue = (-1)^{popcount(z_bits & x)} + let mut parity = 0u32; + for (i, &bit) in outcome.iter().enumerate() { + if bit && stab.z_bits.get_bit(i) { + parity += 1; + } + } + // Stabilizer eigenvalue should be +1 on support states + if !parity.is_multiple_of(2) { + return false; // eigenvalue = -1, not in support + } + } + true +} + +/// Flip outcome bits according to the X-component of a Pauli. +fn flip_outcome(outcome: &[bool], pauli: &Bm) -> Vec { + outcome + .iter() + .enumerate() + .map(|(i, &bit)| if pauli.has_x(i) { !bit } else { bit }) + .collect() +} + +/// Compute ζ = number of qubits whose Z-basis outcome is non-deterministic. +/// +/// ζ = n - (number of independent Z-type stabilizer generators). +fn compute_zeta(n: usize, stabilizers: &[Bm]) -> usize { + // Count independent Z-type stabilizers (no X component). + // Extract Z-parts as Bm (store z in x_bits for GF2 rank). + let z_stabs: Vec = stabilizers + .iter() + .filter(|s| s.x_bits.is_zero()) + .map(|s| Bm { + x_bits: s.z_bits.clone(), + z_bits: smallvec::SmallVec::default(), + }) + .collect(); + + let rank = gf2_rank_bitmask(&z_stabs, n); + n.saturating_sub(rank) +} + +/// GF(2) rank of binary vectors stored as Bm x_bits. +fn gf2_rank_bitmask(vectors: &[Bm], max_bits: usize) -> usize { + let mut rows: Vec = vectors.to_vec(); + let mut rank = 0; + + for bit in 0..max_bits { + if rank >= rows.len() { + break; + } + if rows[rank..] + .iter() + .all(pecos_core::PauliBitmaskGeneric::is_identity) + { + break; + } + if let Some(pivot) = rows[rank..].iter().position(|r| r.x_bits.get_bit(bit)) { + rows.swap(rank, rank + pivot); + let pivot_val = rows[rank].clone(); + for (r, row) in rows.iter_mut().enumerate() { + if r != rank && row.x_bits.get_bit(bit) { + *row = row.multiply(&pivot_val); + } + } + rank += 1; + } + } + + rank +} + +#[cfg(test)] +mod tests { + use super::*; + + fn xx() -> Bm { + Bm::x(0).multiply(&Bm::x(1)) + } + fn zz() -> Bm { + Bm::z(0).multiply(&Bm::z(1)) + } + + #[test] + fn test_single_qubit_z_basis() { + // |0⟩ state: stabilizer = +Z. Outcome 0 is deterministic. + let stabs = vec![Bm::z(0)]; + let outcome_0 = vec![false]; // |0⟩ + let outcome_1 = vec![true]; // |1⟩ + + assert!(is_in_support(&outcome_0, &stabs)); + assert!(!is_in_support(&outcome_1, &stabs)); + + // With S_X noise: flips |0⟩ to |1⟩ + let gens = vec![PropagatedEeg { + eeg_type: EegType::S, + label: Bm::x(0), + label2: None, + coeff: -0.01, + source: None, + }]; + + let p0 = outcome_probability(&gens, &outcome_0, &stabs); + let p1 = outcome_probability(&gens, &outcome_1, &stabs); + + // p(0) ≈ 1 - 0.01 = 0.99 (S_X moves probability from 0 to 1) + // p(1) ≈ 0 + 0.01 = 0.01 + assert!((p0.noiseless - 1.0).abs() < 1e-10); + assert!((p0.s_correction - 0.01).abs() < 1e-10); + assert!((p0.h_correction).abs() < 1e-10); // no H generators + assert!((p0.total - 0.99).abs() < 0.02); + + assert!((p1.noiseless - 0.0).abs() < 1e-10); + assert!((p1.s_correction + 0.01).abs() < 1e-10); + } + + #[test] + fn test_h_type_diagonal_correction() { + // |+⟩ state: stabilizer = +X. Z-basis outcomes 0,1 each with prob 1/2. + // With H_Z noise (coherent Z rotation): shifts probability from 0 to 1. + let stabs = vec![Bm::x(0)]; // |+⟩ + let outcome_0 = vec![false]; + let outcome_1 = vec![true]; + + let gens = vec![PropagatedEeg { + eeg_type: EegType::H, + label: Bm::z(0), // H_Z: Z rotation + label2: None, + coeff: 0.1, + source: None, + }]; + + let p0 = outcome_probability(&gens, &outcome_0, &stabs); + let p1 = outcome_probability(&gens, &outcome_1, &stabs); + + // |+⟩ support: {0, 1} with ζ=1. Both outcomes in support. + assert!((p0.noiseless - 0.5).abs() < 1e-10); + assert!((p1.noiseless - 0.5).abs() < 1e-10); + + // H_Z diagonal α: Z flips no bits (a_Z = 0), so x⊕a = x. + // α(S_Z) = [x ∈ supp] - [x ∈ supp] = 0 for Z-type Paulis. + // So the H·H diagonal correction via S_P analogy should be 0 + // (Z has no X component, doesn't flip outcome bits). + assert!((p0.h_correction).abs() < 1e-10); + + // H_X noise would flip bits: + let gens_x = vec![PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), // H_X + label2: None, + coeff: 0.1, + source: None, + }]; + let p0x = outcome_probability(&gens_x, &outcome_0, &stabs); + // X flips bit: α = [1∈supp] - [0∈supp] = 1-1 = 0 (both in support) + // So h_correction = 0 for |+⟩ with H_X too (both outcomes in support) + assert!((p0x.h_correction).abs() < 1e-10); + } + + #[test] + fn test_h_type_shifts_probability() { + // |0⟩ state with H_X noise: X flips from |0⟩ to |1⟩ + let stabs = vec![Bm::z(0)]; // |0⟩ + let outcome_0 = vec![false]; + let outcome_1 = vec![true]; + + let gens = vec![PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), // H_X + label2: None, + coeff: 0.1, // small angle + source: None, + }]; + + let p0 = outcome_probability(&gens, &outcome_0, &stabs); + let p1 = outcome_probability(&gens, &outcome_1, &stabs); + + // |0⟩: only outcome 0 is in support. + // α(S_X) for outcome 0: [1∈supp] - [0∈supp] = 0-1 = -1 + // Diagonal H·H: h² · α = 0.01 · (-1) = -0.01 + assert!((p0.h_correction + 0.01).abs() < 1e-10); + assert!((p0.total - 0.99).abs() < 0.02); + + // α(S_X) for outcome 1: [0∈supp] - [1∈supp] = 1-0 = +1 + // h² · α = 0.01 · 1 = +0.01 + assert!((p1.h_correction - 0.01).abs() < 1e-10); + assert!((p1.total - 0.01).abs() < 0.02); + } + + #[test] + fn test_bell_state_support() { + // |Φ+⟩ = (|00⟩+|11⟩)/√2. Stabilizers: +XX, +ZZ + let stabs = vec![xx(), zz()]; + + // Support: {00, 11} (Z-type stabilizer ZZ constrains parity) + assert!(is_in_support(&[false, false], &stabs)); // 00: ZZ eigenvalue = (-1)^0 = +1 + assert!(is_in_support(&[true, true], &stabs)); // 11: ZZ eigenvalue = (-1)^2 = +1 + assert!(!is_in_support(&[false, true], &stabs)); // 01: ZZ eigenvalue = (-1)^1 = -1 + assert!(!is_in_support(&[true, false], &stabs)); // 10: ZZ eigenvalue = (-1)^1 = -1 + } + + #[test] + fn test_zeta_computation() { + // Single qubit |0⟩: 1 Z-type stabilizer, ζ = 1-1 = 0 (deterministic) + assert_eq!(compute_zeta(1, &[Bm::z(0)]), 0); + + // Bell state: 1 Z-type stabilizer (ZZ), ζ = 2-1 = 1 + let bell_stabs = vec![xx(), zz()]; + assert_eq!(compute_zeta(2, &bell_stabs), 1); + + // |+⟩: 0 Z-type stabilizers, ζ = 1-0 = 1 + assert_eq!(compute_zeta(1, &[Bm::x(0)]), 1); + } + + #[test] + fn test_phi_single_qubit() { + // |0⟩: stabilizer +Z + let stabs = vec![Bm::z(0)]; + let phases = vec![false]; // +1 stabilizer + + // Φ(I,I) for outcome 0 (in support): should be 1 + let phi = compute_phi(&Bm::default(), &Bm::default(), &[false], &stabs, &phases); + assert!((phi.0 - 1.0).abs() < 1e-10); + assert!(phi.1.abs() < 1e-10); + + // Φ(I,I) for outcome 1 (not in support): should be 0 + let phi = compute_phi(&Bm::default(), &Bm::default(), &[true], &stabs, &phases); + assert!(phi.0.abs() < 1e-10); + + // Φ(X,X) for outcome 0: ⟨0|X|0⟩² = 0 (X flips to |1⟩ which is not in support... + // wait, x⊕a_X = 1, is |1⟩ in support? No. So Φ = 0. + let phi = compute_phi(&Bm::x(0), &Bm::x(0), &[false], &stabs, &phases); + assert!(phi.0.abs() < 1e-10); + + // Φ(X,X) for outcome 1: ⟨1|X|0⟩·⟨0|X|1⟩ = ⟨1|1⟩·⟨0|0⟩ = 1 + // x⊕a_X = 0, which IS in support. So Φ should be 1. + let phi = compute_phi(&Bm::x(0), &Bm::x(0), &[true], &stabs, &phases); + assert!( + (phi.0 - 1.0).abs() < 1e-10, + "Phi(X,X) at |1> for |0> state: got {phi:?}" + ); + + // Φ(Z,I) for outcome 0: ⟨0|Z|0⟩·⟨0|0⟩ = 1·1 = 1 + let phi = compute_phi(&Bm::z(0), &Bm::default(), &[false], &stabs, &phases); + assert!((phi.0 - 1.0).abs() < 1e-10); + } + + #[test] + fn test_phi_bell_state() { + // |Φ+⟩ = (|00⟩+|11⟩)/√2. Stabilizers: +XX, +ZZ. ζ = 1. + let stabs = vec![xx(), zz()]; + let phases = vec![false, false]; + + // Φ(I,I) for outcome 00 (in support): 2^ζ · |⟨00|Φ+⟩|² = 2 · 1/2 = 1 + let phi = compute_phi( + &Bm::default(), + &Bm::default(), + &[false, false], + &stabs, + &phases, + ); + assert!((phi.0 - 1.0).abs() < 1e-10, "Phi(I,I) at 00: {phi:?}"); + + // Φ(I,I) for outcome 01 (not in support): 0 + let phi = compute_phi( + &Bm::default(), + &Bm::default(), + &[false, true], + &stabs, + &phases, + ); + assert!(phi.0.abs() < 1e-10); + + // Φ(Z0,I) for outcome 00 + let phi = compute_phi(&Bm::z(0), &Bm::default(), &[false, false], &stabs, &phases); + assert!((phi.0 - 1.0).abs() < 1e-10, "Phi(Z0,I) at 00: {phi:?}"); + + // Φ(Z0,I) for outcome 11 + let phi = compute_phi(&Bm::z(0), &Bm::default(), &[true, true], &stabs, &phases); + assert!((phi.0 + 1.0).abs() < 1e-10, "Phi(Z0,I) at 11: {phi:?}"); + } + + #[test] + fn test_bell_state_strong_sim() { + // |Φ+⟩ with S_{Z₀} noise (Z error on qubit 0) + let stabs = vec![xx(), zz()]; + + let gens = vec![PropagatedEeg { + eeg_type: EegType::S, + label: Bm::z(0), // S_Z on qubit 0 + label2: None, + coeff: -0.01, + source: None, + }]; + + // Noiseless: p(00) = p(11) = 1/2, p(01) = p(10) = 0 + let p00 = outcome_probability(&gens, &[false, false], &stabs); + let p11 = outcome_probability(&gens, &[true, true], &stabs); + let p01 = outcome_probability(&gens, &[false, true], &stabs); + let p10 = outcome_probability(&gens, &[true, false], &stabs); + + assert!((p00.noiseless - 0.5).abs() < 1e-10); + assert!((p11.noiseless - 0.5).abs() < 1e-10); + assert!(p01.noiseless.abs() < 1e-10); + assert!(p10.noiseless.abs() < 1e-10); + + // S_{Z₀} on |Φ+⟩: Z₀ maps |Φ+⟩ to |Φ-⟩. No Z-basis effect. + assert!( + p00.s_correction.abs() < 1e-10, + "Z error on Bell state: no Z-basis effect" + ); + assert!(p11.s_correction.abs() < 1e-10); + } +} diff --git a/exp/pecos-eeg/tests/beta_investigation.rs b/exp/pecos-eeg/tests/beta_investigation.rs new file mode 100644 index 000000000..e06084dcd --- /dev/null +++ b/exp/pecos-eeg/tests/beta_investigation.rs @@ -0,0 +1,180 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Investigation: why off-diagonal beta terms don't fire for Z-basis. + +use pecos_core::gate_type::GateType; +use pecos_core::{Gate, GateAngles, GateParams, QubitId}; +use pecos_eeg::Bm; +use pecos_eeg::circuit::{NoiseModel, PropagatedEeg, analyze_expanded}; +use pecos_eeg::eeg::EegType; +use pecos_eeg::expand; +use pecos_eeg::stabilizer::StabilizerGroup; +use pecos_simulators::{CliffordGateable, SparseStab}; + +fn gate(gt: GateType, qubits: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qubits.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } +} + +/// Build a minimal Z-basis circuit: 2 data + 1 X-ancilla, 2 rounds. +fn build_minimal_zbasis() -> Vec { + vec![ + // Init + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), // X-ancilla + // Round 1 + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + gate(GateType::PZ, &[2]), + // Round 2 + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + // Final data readout + gate(GateType::MZ, &[0]), + gate(GateType::MZ, &[1]), + ] +} + +#[test] +fn test_zbasis_generator_labels() { + let gates = build_minimal_zbasis(); + let expanded = expand::expand_circuit(&gates); + + eprintln!( + "Expanded circuit: {} qubits ({} original + {} aux)", + expanded.num_qubits, + expanded.num_original_qubits, + expanded.num_qubits - expanded.num_original_qubits + ); + eprintln!("Measurement mapping:"); + for (i, (&aux, &orig)) in expanded + .measurement_qubit + .iter() + .zip(expanded.original_measured_qubit.iter()) + .enumerate() + { + eprintln!(" meas {i}: aux={aux} orig={orig}"); + } + + let noise = NoiseModel::coherent_only(0.001); + let result = analyze_expanded(&expanded.gates, &noise); + + let h_gens: Vec<&PropagatedEeg> = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::H) + .collect(); + + eprintln!("\nH generators ({}):", h_gens.len()); + for (i, g) in h_gens.iter().enumerate() { + let orig = expanded.map_to_original_frame(&g.label); + eprintln!( + " [{i}] expanded={:?} coeff={:.6} original_frame={:?}", + g.label, g.coeff, orig + ); + } + + // Check products of all pairs + eprintln!("\nPairwise products:"); + let stab_group = StabilizerGroup::from_circuit(&gates, expanded.num_original_qubits); + + for j in 0..h_gens.len() { + for k in (j + 1)..h_gens.len() { + let qj = &h_gens[j].label; + let qk = &h_gens[k].label; + if !qj.commutes_with(qk) { + continue; // Skip anticommuting pairs + } + let product = qj.multiply(qk); + let orig_product = expanded.map_to_original_frame(&product); + let is_stab = stab_group.is_stabilizer(&orig_product); + + if is_stab.is_some() || !orig_product.is_identity() { + eprintln!( + " [{j},{k}] commute=true product_orig={orig_product:?} is_stab={is_stab:?}" + ); + } + } + } +} + +#[test] +fn test_zbasis_stabilizer_group() { + let gates = build_minimal_zbasis(); + // Exclude final MZ readout — keep syndrome MZ + let last_non_mz = gates + .iter() + .rposition(|g| g.gate_type != GateType::MZ) + .unwrap(); + let gates_pre = &gates[..=last_non_mz]; + let stab_group = StabilizerGroup::from_circuit(gates_pre, 3); + + // Dump actual generators + eprintln!("Stabilizer generators:"); + // Run SparseStab manually to see generators + let mut sim = SparseStab::with_seed(3, 0); + for g in gates_pre { + let qs: Vec = g.qubits.iter().copied().collect(); + match g.gate_type { + GateType::PZ => { + for &q in &qs { + sim.pz(&[q]); + } + } + GateType::H => { + sim.h(&qs); + } + GateType::CX if qs.len() >= 2 => { + sim.cx(&[(qs[0], qs[1])]); + } + GateType::MZ => { + let _r = sim.mz(&qs); + eprintln!(" MZ({qs:?})"); + } + _ => {} + } + } + let stab_gens = sim.stabs().generators(); + for (i, g) in stab_gens.iter().enumerate() { + eprintln!(" stab[{i}] = {g:?}"); + } + + // Check what's in the stabilizer group + eprintln!("\nStabilizer group membership checks:"); + let test_paulis = vec![ + ("X0", Bm::x(0)), + ("X1", Bm::x(1)), + ("X0X1", Bm::x(0).multiply(&Bm::x(1))), + ("Z0", Bm::z(0)), + ("Z1", Bm::z(1)), + ("Z0Z1", Bm::z(0).multiply(&Bm::z(1))), + ("Z2", Bm::z(2)), + ]; + + for (name, p) in &test_paulis { + let result = stab_group.is_stabilizer(p); + eprintln!(" {name}: {result:?}"); + } + + // X0*X1 should be a stabilizer (from X-type syndrome extraction) + assert_eq!( + stab_group.is_stabilizer(&Bm::x(0).multiply(&Bm::x(1))), + Some(true), + "X0*X1 should be stabilizer after 2 rounds of X-stabilizer measurement" + ); +} diff --git a/exp/pecos-eeg/tests/generator_trace.rs b/exp/pecos-eeg/tests/generator_trace.rs new file mode 100644 index 000000000..2158b871a --- /dev/null +++ b/exp/pecos-eeg/tests/generator_trace.rs @@ -0,0 +1,414 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Trace generator propagation for d=2 Z-basis surface code. +//! Diagnose why D1 and D2 have identical EEG probabilities +//! when `StateVec` shows they should differ. + +use std::collections::BTreeMap; + +use pecos_core::gate_type::GateType; +use pecos_core::{Gate, GateAngles, GateParams, QubitId}; +use pecos_eeg::Bm; +use pecos_eeg::circuit::{NoiseModel, analyze_expanded}; +use pecos_eeg::dem_mapping::*; +use pecos_eeg::eeg::EegType; +use pecos_eeg::expand; +use pecos_eeg::stabilizer::StabilizerGroup; + +fn gate(gt: GateType, qubits: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qubits.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } +} + +/// Build the d=2 Z-basis surface code circuit (2 rounds). +/// Matches what `LogicalCircuitBuilder` produces. +fn build_d2_zbasis() -> Vec { + vec![ + // Init + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::PZ, &[3]), + gate(GateType::PZ, &[4]), + gate(GateType::PZ, &[5]), + gate(GateType::PZ, &[6]), + // Round 1 + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::CX, &[1, 6]), + gate(GateType::CX, &[5, 3]), // tick 3 + gate(GateType::CX, &[3, 6]), + gate(GateType::CX, &[5, 2]), // tick 4 + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[0, 6]), // tick 5 + gate(GateType::CX, &[4, 0]), + gate(GateType::CX, &[2, 6]), // tick 6 + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::MZ, &[4]), + gate(GateType::MZ, &[5]), + gate(GateType::MZ, &[6]), + // Reset + gate(GateType::PZ, &[4]), + gate(GateType::PZ, &[5]), + gate(GateType::PZ, &[6]), + // Round 2 + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::CX, &[1, 6]), + gate(GateType::CX, &[5, 3]), // tick 11 + gate(GateType::CX, &[3, 6]), + gate(GateType::CX, &[5, 2]), // tick 12 + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[0, 6]), // tick 13 + gate(GateType::CX, &[4, 0]), + gate(GateType::CX, &[2, 6]), // tick 14 + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::MZ, &[4]), + gate(GateType::MZ, &[5]), + gate(GateType::MZ, &[6]), + // Final data readout + gate(GateType::MZ, &[0]), + gate(GateType::MZ, &[1]), + gate(GateType::MZ, &[2]), + gate(GateType::MZ, &[3]), + ] +} + +#[test] +fn trace_d2_zbasis_generators() { + let gates = build_d2_zbasis(); + let expanded = expand::expand_circuit(&gates); + let noise = NoiseModel::coherent_only(0.01); + let result = analyze_expanded(&expanded.gates, &noise); + + eprintln!( + "Expanded: {} qubits ({} orig + {} aux), {} measurements", + expanded.num_qubits, + expanded.num_original_qubits, + expanded.num_qubits - expanded.num_original_qubits, + expanded.measurement_qubit.len() + ); + + eprintln!("\nMeasurement mapping:"); + for (i, (&aux, &orig)) in expanded + .measurement_qubit + .iter() + .zip(expanded.original_measured_qubit.iter()) + .enumerate() + { + eprintln!(" meas[{i}]: aux=q{aux}, orig=q{orig}"); + } + + // Detectors: D1 = Z_{aux_meas0} * Z_{aux_meas3} (ancilla 4, rounds 1&2) + // D2 = Z_{aux_meas1} * Z_{aux_meas4} (ancilla 5, rounds 1&2) + let aux_m0 = expanded.measurement_qubit[0]; // q4 round 1 + let aux_m1 = expanded.measurement_qubit[1]; // q5 round 1 + let aux_m3 = expanded.measurement_qubit[3]; // q4 round 2 + let aux_m4 = expanded.measurement_qubit[4]; // q5 round 2 + + let d1_stab = Bm::z(aux_m0).multiply(&Bm::z(aux_m3)); + let d2_stab = Bm::z(aux_m1).multiply(&Bm::z(aux_m4)); + + eprintln!("\nD1 stabilizer: Z on aux q{aux_m0} and q{aux_m3} (ancilla 4 rounds 1&2)"); + eprintln!("D2 stabilizer: Z on aux q{aux_m1} and q{aux_m4} (ancilla 5 rounds 1&2)"); + + let _dets = [ + Detector { + id: 1, + stabilizer: d1_stab.clone(), + }, + Detector { + id: 2, + stabilizer: d2_stab.clone(), + }, + ]; + + // Classify each H generator + let h_gens: Vec<_> = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::H) + .collect(); + + eprintln!("\n{} H generators. Classification:", h_gens.len()); + + let mut d1_gens = Vec::new(); + let mut d2_gens = Vec::new(); + + for g in &h_gens { + let flips_d1 = !g.label.commutes_with(&d1_stab); + let flips_d2 = !g.label.commutes_with(&d2_stab); + + if flips_d1 || flips_d2 { + let orig = expanded.map_to_original_frame(&g.label); + eprintln!( + " {:?} coeff={:.6} -> orig={:?} flips: D1={} D2={}", + g.label, g.coeff, orig, flips_d1, flips_d2 + ); + + if flips_d1 { + d1_gens.push((g.label.clone(), g.coeff)); + } + if flips_d2 { + d2_gens.push((g.label.clone(), g.coeff)); + } + } + } + + eprintln!("\nD1 generators: {} (ancilla 4)", d1_gens.len()); + for (label, coeff) in &d1_gens { + eprintln!(" {label:?} coeff={coeff:.6}"); + } + + eprintln!("\nD2 generators: {} (ancilla 5)", d2_gens.len()); + for (label, coeff) in &d2_gens { + eprintln!(" {label:?} coeff={coeff:.6}"); + } + + // After BCH combination (same label → sum coefficients) + let mut d1_bch: BTreeMap = BTreeMap::new(); + let mut d2_bch: BTreeMap = BTreeMap::new(); + for (l, c) in &d1_gens { + *d1_bch.entry(l.clone()).or_default() += c; + } + for (l, c) in &d2_gens { + *d2_bch.entry(l.clone()).or_default() += c; + } + + eprintln!("\nD1 after BCH: {} distinct labels", d1_bch.len()); + for (l, c) in &d1_bch { + eprintln!(" {l:?} rate={c:.6}"); + } + + eprintln!("\nD2 after BCH: {} distinct labels", d2_bch.len()); + for (l, c) in &d2_bch { + eprintln!(" {l:?} rate={c:.6}"); + } + + // Verify asymmetry in generator counts + assert_ne!( + d1_bch.len(), + d2_bch.len(), + "D1 and D2 should have different numbers of BCH-combined generators" + ); + + // Compute probabilities manually to trace the beta function + let gates_pre = crate::exclude_final_readout(&gates); + let stab_group = StabilizerGroup::from_circuit(&gates_pre, expanded.num_original_qubits); + + // Check what the stabilizer group contains + eprintln!("\nStabilizer group membership checks:"); + let test_paulis = vec![ + ("Z0", Bm::z(0)), + ("Z1", Bm::z(1)), + ("Z2", Bm::z(2)), + ("Z3", Bm::z(3)), + ("Z0Z1", Bm::z(0).multiply(&Bm::z(1))), + ("Z0Z3", Bm::z(0).multiply(&Bm::z(3))), + ("X0X1", Bm::x(0).multiply(&Bm::x(1))), + ]; + for (name, p) in &test_paulis { + let result = stab_group.is_stabilizer(p); + eprintln!(" {name}: {result:?}"); + } + + eprintln!("\nPre-readout gates count: {}", gates_pre.len()); + eprintln!("Original gates count: {}", gates.len()); + + // Dump raw SparseStab generators + { + use pecos_simulators::{CliffordGateable, SparseStab}; + let mut sim = SparseStab::with_seed(7, 0); + for g in &gates_pre { + let qs: Vec = g.qubits.iter().copied().collect(); + if qs.is_empty() { + continue; + } + match g.gate_type { + GateType::PZ => { + for &q in &qs { + sim.pz(&[q]); + } + } + GateType::H => { + sim.h(&qs); + } + GateType::CX if qs.len() >= 2 => { + sim.cx(&[(qs[0], qs[1])]); + } + GateType::MZ => { + let _ = sim.mz(&qs); + } + _ => {} + } + } + let stabs = sim.stabs(); + let n_gens = stabs.num_generators(); + eprintln!("\nSparseStab generators ({n_gens}):"); + for i in 0..n_gens { + let ps = stabs.generator(i); + let phase = stabs.generator_phase(i); + eprintln!(" [{i}] phase={phase:?} {ps}"); + } + + // Direct test: can find_pauli_sign find Z0? + let result = stabs.find_pauli_sign( + sim.destabs(), + std::iter::empty::(), + std::iter::once(0usize), + 0, + ); + eprintln!("\nfind_pauli_sign(Z0) WITH MZ = {result:?}"); + + // Try without MZ: skip measurements in stabilizer computation + let mut sim2 = SparseStab::with_seed(7, 0); + for g in &gates_pre { + let qs: Vec = g.qubits.iter().copied().collect(); + if qs.is_empty() { + continue; + } + match g.gate_type { + GateType::PZ => { + for &q in &qs { + sim2.pz(&[q]); + } + } + GateType::H => { + sim2.h(&qs); + } + GateType::CX if qs.len() >= 2 => { + sim2.cx(&[(qs[0], qs[1])]); + } + _ => {} + } + } + let stabs2 = sim2.stabs(); + let result2 = stabs2.find_pauli_sign( + sim2.destabs(), + std::iter::empty::(), + std::iter::once(0usize), + 0, + ); + eprintln!("find_pauli_sign(Z0) WITHOUT MZ = {result2:?}"); + + // Check X0X1 without MZ + let result3 = + stabs2.find_pauli_sign(sim2.destabs(), [0usize, 1], std::iter::empty::(), 0); + eprintln!("find_pauli_sign(X0X1) WITHOUT MZ = {result3:?}"); + } + + // Check: how many qubits in the stabilizer group? And test Z0Z1Z2Z3 + let z_all = Bm::z(0) + .multiply(&Bm::z(1)) + .multiply(&Bm::z(2)) + .multiply(&Bm::z(3)); + eprintln!("Z0Z1Z2Z3: {:?}", stab_group.is_stabilizer(&z_all)); + let _z01 = Bm::z(0).multiply(&Bm::z(1)); + let z23 = Bm::z(2).multiply(&Bm::z(3)); + eprintln!("Z2Z3: {:?}", stab_group.is_stabilizer(&z23)); + eprintln!( + "Z0Z1Z2: {:?}", + stab_group.is_stabilizer(&Bm::z(0).multiply(&Bm::z(1)).multiply(&Bm::z(2))) + ); + eprintln!( + "Z1Z2: {:?}", + stab_group.is_stabilizer(&Bm::z(1).multiply(&Bm::z(2))) + ); + + for (det_name, bch) in [("D1", &d1_bch), ("D2", &d2_bch)] { + let labels: Vec = bch.keys().cloned().collect(); + let coeffs: Vec = bch.values().copied().collect(); + let n = labels.len(); + + let mut diag = 0.0; + let mut offdiag = 0.0; + let mut offdiag_zero = 0; + let mut offdiag_plus = 0; + let mut offdiag_minus = 0; + let mut offdiag_anticommute = 0; + + for j in 0..n { + diag += coeffs[j] * coeffs[j]; + for k in (j + 1)..n { + if !labels[j].commutes_with(&labels[k]) { + offdiag_anticommute += 1; + continue; + } + let product = labels[j].multiply(&labels[k]); + let orig_product = expanded.map_to_original_frame(&product); + + if orig_product.is_identity() { + offdiag += 2.0 * coeffs[j] * coeffs[k]; + offdiag_plus += 1; + continue; + } + match stab_group.is_stabilizer(&orig_product) { + Some(true) => { + offdiag += 2.0 * coeffs[j] * coeffs[k]; + offdiag_plus += 1; + } + Some(false) => { + offdiag -= 2.0 * coeffs[j] * coeffs[k]; + offdiag_minus += 1; + } + None => { + offdiag_zero += 1; + eprintln!( + " beta=0: {:?} * {:?} = {:?} (orig: {:?})", + labels[j], labels[k], product, orig_product + ); + } + } + } + } + + let total = diag + offdiag; + eprintln!("\n{det_name} probability breakdown:"); + eprintln!(" Diagonal: {diag:.8}"); + eprintln!( + " Off-diagonal: {offdiag:.8} (+{offdiag_plus} pairs, -{offdiag_minus} pairs, 0:{offdiag_zero} pairs, anticommute:{offdiag_anticommute})" + ); + eprintln!(" Total: {total:.8}"); + } +} + +fn exclude_final_readout(gates: &[Gate]) -> Vec { + use pecos_core::gate_type::GateType; + let mut ancilla_qubits = std::collections::HashSet::new(); + let mut past_init = false; + for g in gates { + if past_init && (g.gate_type == GateType::PZ || g.gate_type == GateType::QAlloc) { + for q in &g.qubits { + ancilla_qubits.insert(q.index()); + } + } + if g.gate_type != GateType::PZ && g.gate_type != GateType::QAlloc { + past_init = true; + } + } + let mut end = gates.len(); + for g in gates.iter().rev() { + if g.gate_type != GateType::MZ { + break; + } + if g.qubits + .iter() + .all(|q| !ancilla_qubits.contains(&q.index())) + { + end -= 1; + } else { + break; + } + } + gates[..end].to_vec() +} diff --git a/exp/pecos-eeg/tests/stabilizer_audit.rs b/exp/pecos-eeg/tests/stabilizer_audit.rs new file mode 100644 index 000000000..5e9fcea9c --- /dev/null +++ b/exp/pecos-eeg/tests/stabilizer_audit.rs @@ -0,0 +1,228 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Audit: does the `StabilizerGroup` correctly identify all stabilizers? +//! Test by generating all 2^n products of n generators and checking +//! that `is_stabilizer` returns Some for each. + +use pecos_core::gate_type::GateType; +use pecos_core::{Gate, GateAngles, GateParams, QubitId}; +use pecos_eeg::Bm; +use pecos_eeg::stabilizer::StabilizerGroup; +use pecos_simulators::{CliffordGateable, SparseStab}; + +fn gate(gt: GateType, qubits: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qubits.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } +} + +/// Extract generators as Bm from `SparseStab`. +fn extract_generators(sim: &SparseStab) -> Vec { + let stabs = sim.stabs(); + let n = stabs.num_generators(); + let mut gens = Vec::with_capacity(n); + for i in 0..n { + let ps = stabs.generator(i); + gens.push(pecos_eeg::dem_mapping::pauli_string_to_bitmask(&ps)); + } + gens +} + +/// Check that `StabilizerGroup.is_stabilizer` returns Some for ALL products +/// of the `SparseStab` generators (which are by definition in the group). +fn audit_stabilizer_group(label: &str, gates: &[Gate], num_qubits: usize) { + let stab_group = StabilizerGroup::from_circuit(gates, num_qubits); + + // Also build a raw SparseStab to extract generators + let mut sim = SparseStab::with_seed(num_qubits, 0); + for g in gates { + let qs: Vec = g.qubits.iter().copied().collect(); + if qs.is_empty() { + continue; + } + match g.gate_type { + GateType::PZ | GateType::QAlloc => { + for &q in &qs { + sim.pz(&[q]); + } + } + GateType::H => { + sim.h(&qs); + } + GateType::SZ => { + sim.sz(&qs); + } + GateType::SZdg => { + sim.szdg(&qs); + } + GateType::X => { + sim.x(&qs); + } + GateType::Y => { + sim.y(&qs); + } + GateType::Z => { + sim.z(&qs); + } + GateType::CX if qs.len() >= 2 => { + sim.cx(&[(qs[0], qs[1])]); + } + GateType::CZ if qs.len() >= 2 => { + sim.cz(&[(qs[0], qs[1])]); + } + GateType::MZ => { + sim.mz(&qs); + } + _ => {} + } + } + + let generators = extract_generators(&sim); + let n = generators.len(); + + eprintln!("\n{label}: {n} generators on {num_qubits} qubits"); + for (i, g) in generators.iter().enumerate() { + eprintln!(" gen[{i}] = {g:?}"); + } + + // Test all 2^n products (for small n) + let max_subsets = if n <= 10 { 1 << n } else { 1024 }; // cap at 1024 for large n + let mut failures = Vec::new(); + + for mask in 0..max_subsets { + let mut product = Bm::default(); + for (i, generator) in generators.iter().enumerate().take(n) { + if mask & (1 << i) != 0 { + product = product.multiply(generator); + } + } + + let result = stab_group.is_stabilizer(&product); + if result.is_none() && !product.is_identity() { + failures.push((mask, product)); + } + } + + if failures.is_empty() { + eprintln!(" OK: all {max_subsets} products correctly identified"); + } else { + eprintln!(" FAILURES: {} products not found:", failures.len()); + for (mask, product) in &failures { + let gens_used: Vec = (0..n).filter(|&i| mask & (1 << i) != 0).collect(); + eprintln!(" mask={mask:#06b} gens={gens_used:?} product={product:?}"); + } + } + + assert!( + failures.is_empty(), + "{label}: {}/{max_subsets} stabilizer products not found by is_stabilizer", + failures.len() + ); +} + +#[test] +fn audit_simple_states() { + // |0>: stabilizer = Z + audit_stabilizer_group("|0>", &[], 1); + + // |+>: stabilizer = X + audit_stabilizer_group("|+>", &[gate(GateType::H, &[0])], 1); + + // Bell state + audit_stabilizer_group( + "|Phi+>", + &[gate(GateType::H, &[0]), gate(GateType::CX, &[0, 1])], + 2, + ); +} + +#[test] +fn audit_syndrome_extraction() { + // Simple 2-qubit Z-check with ancilla: PZ(0,1,2), CX(0,2), CX(1,2), MZ(2) + audit_stabilizer_group( + "Z-check 2q", + &[ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::CX, &[0, 2]), + gate(GateType::CX, &[1, 2]), + gate(GateType::MZ, &[2]), + ], + 3, + ); + + // X-check: H(2), CX(2,0), CX(2,1), H(2), MZ(2) + audit_stabilizer_group( + "X-check 2q", + &[ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + ], + 3, + ); +} + +#[test] +fn audit_d2_zbasis_pre_readout() { + // d=2 Z-basis surface code, 2 rounds, pre-readout circuit + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::PZ, &[3]), + gate(GateType::PZ, &[4]), + gate(GateType::PZ, &[5]), + gate(GateType::PZ, &[6]), + // Round 1 + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::CX, &[1, 6]), + gate(GateType::CX, &[5, 3]), + gate(GateType::CX, &[3, 6]), + gate(GateType::CX, &[5, 2]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[0, 6]), + gate(GateType::CX, &[4, 0]), + gate(GateType::CX, &[2, 6]), + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::MZ, &[4]), + gate(GateType::MZ, &[5]), + gate(GateType::MZ, &[6]), + // Reset + gate(GateType::PZ, &[4]), + gate(GateType::PZ, &[5]), + gate(GateType::PZ, &[6]), + // Round 2 + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::CX, &[1, 6]), + gate(GateType::CX, &[5, 3]), + gate(GateType::CX, &[3, 6]), + gate(GateType::CX, &[5, 2]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[0, 6]), + gate(GateType::CX, &[4, 0]), + gate(GateType::CX, &[2, 6]), + gate(GateType::H, &[4]), + gate(GateType::H, &[5]), + gate(GateType::MZ, &[4]), + gate(GateType::MZ, &[5]), + gate(GateType::MZ, &[6]), + ]; + audit_stabilizer_group("d=2 Z-basis pre-readout", &gates, 7); +} diff --git a/exp/pecos-eeg/tests/statevec_comparison.rs b/exp/pecos-eeg/tests/statevec_comparison.rs new file mode 100644 index 000000000..ed387111f --- /dev/null +++ b/exp/pecos-eeg/tests/statevec_comparison.rs @@ -0,0 +1,1974 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Ground-truth comparison: EEG analytical DEM vs `StateVec` simulation. +//! +//! Uses Bell-state parity circuit where idle RZ noise creates detectable +//! parity violations. Without noise, MZ parity is always even. +//! With RZ(θ) noise after CX, P(odd parity) = sin²(θ). +//! EEG predicts ≈ θ² at leading order. + +use pecos_core::gate_type::GateType; +use pecos_core::pauli::pauli_bitmask::BitmaskStorage; +use pecos_core::{Angle64, Gate, GateAngles, GateParams, QubitId}; +use pecos_eeg::Bm; +use pecos_eeg::circuit::{NoiseModel, analyze_expanded}; +use pecos_eeg::dem_mapping::{Detector, build_dem_with_stabilizers}; +use pecos_eeg::expand; +use pecos_eeg::noise::UniformNoise; +use pecos_eeg::stabilizer::StabilizerGroup; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, StateVec}; + +fn gate(gt: GateType, qubits: &[usize]) -> Gate { + Gate { + gate_type: gt, + qubits: qubits.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: pecos_core::GateMeasIds::new(), + channel: None, + } +} + +fn qid(q: usize) -> QubitId { + QubitId(q) +} + +fn u64_to_f64(value: u64) -> f64 { + f64::from(u32::try_from(value).expect("test sample count fits in u32")) +} + +fn usize_to_f64(value: usize) -> f64 { + f64::from(u32::try_from(value).expect("test dimension fits in u32")) +} + +fn bit_to_f64(value: usize) -> f64 { + f64::from(u8::try_from(value).expect("bit value fits in u8")) +} + +// ============================================================ +// Shared helpers for EEG analysis and StateVec simulation +// ============================================================ + +/// Run EEG on a gate list with a parity detector over all measurements. +fn eeg_detection_prob(gates: &[Gate], theta: f64) -> f64 { + let expanded = expand::expand_circuit(gates); + let noise = NoiseModel::coherent_only(theta); + let result = analyze_expanded(&expanded.gates, &noise); + + // Parity detector: Z on all auxiliary qubits + let mut det_stab = Bm::default(); + for &aux in &expanded.measurement_qubit { + det_stab.z_bits.set_bit(aux); + } + let det = Detector { + id: 0, + stabilizer: det_stab, + }; + + // Stabilizer group from expanded circuit (strip trailing deferred MZ) + let exp_pre: Vec<_> = { + let last = expanded + .gates + .iter() + .rposition(|g| g.gate_type != GateType::MZ) + .unwrap(); + expanded.gates[..=last].to_vec() + }; + let stab_group = StabilizerGroup::from_circuit(&exp_pre, expanded.num_qubits); + + let entries = build_dem_with_stabilizers(&result.generators, &[det], &[], Some(&stab_group)); + + entries.iter().map(|e| e.probability).sum() +} + +/// Run EEG with per-round detectors, return per-detector probabilities. +fn eeg_per_round_probs(gates: &[Gate], theta: f64, num_rounds: usize) -> Vec { + let expanded = expand::expand_circuit(gates); + let noise = NoiseModel::coherent_only(theta); + let result = analyze_expanded(&expanded.gates, &noise); + + // One detector per round (Z on that round's aux qubit) + let dets: Vec = (0..num_rounds) + .map(|r| { + let aux = expanded.measurement_qubit[r]; + Detector { + id: r, + stabilizer: Bm::z(aux), + } + }) + .collect(); + + // Stabilizer group from expanded circuit (strip trailing deferred MZ) + let exp_pre: Vec<_> = { + let last = expanded + .gates + .iter() + .rposition(|g| g.gate_type != GateType::MZ) + .unwrap(); + expanded.gates[..=last].to_vec() + }; + let stab_group = StabilizerGroup::from_circuit(&exp_pre, expanded.num_qubits); + + let entries = build_dem_with_stabilizers(&result.generators, &dets, &[], Some(&stab_group)); + + let mut probs = vec![0.0; num_rounds]; + for e in &entries { + for &d in &e.event.detectors { + probs[d] += e.probability; + } + } + probs +} + +/// Bell-state parity circuit: +/// PZ(0,1), H(0), CX(0,1), [idle RZ], H(0), H(1), MZ(0), MZ(1) +/// +/// Without noise: |Φ+> → H⊗H → |Φ+> → MZ parity always even. +/// With RZ(θ) on both qubits: P(odd parity) = sin²(θ) ≈ θ² at leading order. +#[test] +fn test_eeg_vs_statevec_bell_parity() { + let theta = 0.05; + let num_shots = 100_000; + + // --- EEG analytical path --- + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::H, &[0]), + gate(GateType::CX, &[0, 1]), + // idle RZ(θ) on both qubits is implicit in noise model + gate(GateType::H, &[0]), + gate(GateType::H, &[1]), + gate(GateType::MZ, &[0]), + gate(GateType::MZ, &[1]), + ]; + + let expanded = expand::expand_circuit(&gates); + let noise = NoiseModel::coherent_only(theta); + let result = analyze_expanded(&expanded.gates, &noise); + + // Parity detector: Z_aux0 * Z_aux1 + assert_eq!(expanded.measurement_qubit.len(), 2); + let aux0 = expanded.measurement_qubit[0]; + let aux1 = expanded.measurement_qubit[1]; + let mut det_stab = Bm::default(); + det_stab.z_bits.set_bit(aux0); + det_stab.z_bits.set_bit(aux1); + let det = Detector { + id: 0, + stabilizer: det_stab, + }; + + // Pre-readout stabilizer group (exclude final MZ gates) + // Stabilizer group from expanded circuit (strip trailing deferred MZ) + let exp_pre: Vec<_> = { + let last = expanded + .gates + .iter() + .rposition(|g| g.gate_type != GateType::MZ) + .unwrap(); + expanded.gates[..=last].to_vec() + }; + let stab_group = StabilizerGroup::from_circuit(&exp_pre, expanded.num_qubits); + + let entries = build_dem_with_stabilizers(&result.generators, &[det], &[], Some(&stab_group)); + + let eeg_prob: f64 = entries.iter().map(|e| e.probability).sum(); + + // --- StateVec simulation path --- + let mut odd_parity_count = 0u64; + let mut sim = StateVec::new(2); + + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1)]); + sim.h(&[qid(0)]); + sim.cx(&[(qid(0), qid(1))]); + + // Idle RZ noise (same as EEG coherent_only model) + sim.rz(Angle64::from_radians(theta), &[qid(0)]); + sim.rz(Angle64::from_radians(theta), &[qid(1)]); + + sim.h(&[qid(0)]); + sim.h(&[qid(1)]); + + let r = sim.mz(&[qid(0), qid(1)]); + if r[0].outcome != r[1].outcome { + odd_parity_count += 1; + } + } + + let sv_rate = u64_to_f64(odd_parity_count) / f64::from(num_shots); + let sv_stderr = (sv_rate * (1.0 - sv_rate) / f64::from(num_shots)).sqrt(); + let exact = theta.sin().powi(2); + + eprintln!("theta = {theta}"); + eprintln!("EEG: {eeg_prob:.6}"); + eprintln!("StateVec: {sv_rate:.6} +/- {sv_stderr:.6}"); + eprintln!("Exact: {exact:.6}"); + + // EEG should match StateVec within statistical noise + perturbative error. + // At θ=0.05: exact=sin²(0.05)≈0.002499, EEG leading-order≈θ²≈0.0025 + // Perturbative error is O(θ⁴) ≈ 6.25e-6, well within statistical noise. + let diff = (eeg_prob - sv_rate).abs(); + let tolerance = 5.0 * sv_stderr + theta.powi(4); // 5σ + perturbative bound + assert!( + diff < tolerance, + "EEG ({eeg_prob:.6}) vs StateVec ({sv_rate:.6}): diff={diff:.6} > tol={tolerance:.6}" + ); +} + +/// Same comparison at larger angle to verify scaling. +#[test] +fn test_eeg_vs_statevec_larger_angle() { + let theta = 0.1; + let num_shots = 100_000; + + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::H, &[0]), + gate(GateType::CX, &[0, 1]), + gate(GateType::H, &[0]), + gate(GateType::H, &[1]), + gate(GateType::MZ, &[0]), + gate(GateType::MZ, &[1]), + ]; + + let expanded = expand::expand_circuit(&gates); + let noise = NoiseModel::coherent_only(theta); + let result = analyze_expanded(&expanded.gates, &noise); + + let aux0 = expanded.measurement_qubit[0]; + let aux1 = expanded.measurement_qubit[1]; + let mut det_stab = Bm::default(); + det_stab.z_bits.set_bit(aux0); + det_stab.z_bits.set_bit(aux1); + let det = Detector { + id: 0, + stabilizer: det_stab, + }; + + // Stabilizer group from expanded circuit (strip trailing deferred MZ) + let exp_pre: Vec<_> = { + let last = expanded + .gates + .iter() + .rposition(|g| g.gate_type != GateType::MZ) + .unwrap(); + expanded.gates[..=last].to_vec() + }; + let stab_group = StabilizerGroup::from_circuit(&exp_pre, expanded.num_qubits); + + let entries = build_dem_with_stabilizers(&result.generators, &[det], &[], Some(&stab_group)); + + let eeg_prob: f64 = entries.iter().map(|e| e.probability).sum(); + + let mut odd_parity_count = 0u64; + let mut sim = StateVec::new(2); + + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1)]); + sim.h(&[qid(0)]); + sim.cx(&[(qid(0), qid(1))]); + sim.rz(Angle64::from_radians(theta), &[qid(0)]); + sim.rz(Angle64::from_radians(theta), &[qid(1)]); + sim.h(&[qid(0)]); + sim.h(&[qid(1)]); + let r = sim.mz(&[qid(0), qid(1)]); + if r[0].outcome != r[1].outcome { + odd_parity_count += 1; + } + } + + let sv_rate = u64_to_f64(odd_parity_count) / f64::from(num_shots); + let sv_stderr = (sv_rate * (1.0 - sv_rate) / f64::from(num_shots)).sqrt(); + let exact = theta.sin().powi(2); + + eprintln!("theta = {theta}"); + eprintln!("EEG: {eeg_prob:.6}"); + eprintln!("StateVec: {sv_rate:.6} +/- {sv_stderr:.6}"); + eprintln!("Exact: {exact:.6}"); + + // At θ=0.1: exact≈0.00998, EEG≈θ²≈0.01, perturbative error≈O(θ⁴)≈0.0001 + // Allow larger tolerance for bigger angle + let diff = (eeg_prob - sv_rate).abs(); + let tolerance = 5.0 * sv_stderr + 2.0 * theta.powi(4); + assert!( + diff < tolerance, + "EEG ({eeg_prob:.6}) vs StateVec ({sv_rate:.6}): diff={diff:.6} > tol={tolerance:.6}" + ); +} + +// ============================================================ +// Benchmark sweeps (run with: cargo test -p pecos-eeg --test statevec_comparison -- --ignored --nocapture) +// ============================================================ + +/// Sweep theta for the Bell parity circuit. +/// Exact answer: sin²(θ). EEG leading-order: θ². +#[test] +#[ignore = "benchmark sweep; run manually with --ignored --nocapture"] +fn bench_bell_parity_theta_sweep() { + let num_shots = 200_000; + + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::H, &[0]), + gate(GateType::CX, &[0, 1]), + gate(GateType::H, &[0]), + gate(GateType::H, &[1]), + gate(GateType::MZ, &[0]), + gate(GateType::MZ, &[1]), + ]; + + eprintln!("\n=== Bell parity: EEG vs StateVec vs Exact ==="); + eprintln!( + "{:>8} {:>10} {:>10} {:>10} {:>10} {:>10}", + "theta", "EEG", "StateVec", "SV_stderr", "Exact", "EEG/Exact" + ); + + for &theta in &[0.01, 0.02, 0.05, 0.1, 0.15, 0.2, 0.3, 0.5] { + let eeg_prob = eeg_detection_prob(&gates, theta); + + let mut odd = 0u64; + let mut sim = StateVec::new(2); + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1)]); + sim.h(&[qid(0)]); + sim.cx(&[(qid(0), qid(1))]); + sim.rz(Angle64::from_radians(theta), &[qid(0)]); + sim.rz(Angle64::from_radians(theta), &[qid(1)]); + sim.h(&[qid(0)]); + sim.h(&[qid(1)]); + let r = sim.mz(&[qid(0), qid(1)]); + if r[0].outcome != r[1].outcome { + odd += 1; + } + } + + let sv = u64_to_f64(odd) / f64::from(num_shots); + let se = (sv * (1.0 - sv) / f64::from(num_shots)).sqrt(); + let exact = theta.sin().powi(2); + let ratio = if exact > 1e-10 { + eeg_prob / exact + } else { + f64::NAN + }; + + eprintln!( + "{theta:>8.3} {eeg_prob:>10.6} {sv:>10.6} {se:>10.6} {exact:>10.6} {ratio:>10.4}" + ); + } +} + +/// Multi-round X-check: 2 data qubits, 1 ancilla, N rounds of X-check with reset. +/// Data prepared in |++>, ancilla measures X0*X1 each round. +#[test] +#[ignore = "benchmark sweep; run manually with --ignored --nocapture"] +fn bench_x_check_multi_round() { + let num_shots = 200_000; + + eprintln!("\n=== Multi-round X-check (2 data + 1 ancilla) ==="); + + for &num_rounds in &[1, 2, 3, 4] { + // Build circuit: PZ(0,1,2), H(0), H(1), then N rounds of X-check + let mut gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[0]), + gate(GateType::H, &[1]), + ]; + + for _ in 0..num_rounds { + gates.push(gate(GateType::H, &[2])); + gates.push(gate(GateType::CX, &[2, 0])); + gates.push(gate(GateType::CX, &[2, 1])); + gates.push(gate(GateType::H, &[2])); + gates.push(gate(GateType::MZ, &[2])); + gates.push(gate(GateType::PZ, &[2])); + } + // Remove trailing PZ (no reset after last round) + gates.pop(); + + for &theta in &[0.01, 0.05, 0.1] { + let eeg_probs = eeg_per_round_probs(&gates, theta, num_rounds); + + // StateVec: run circuit with idle RZ after each CX + let mut round_detections = vec![0u64; num_rounds]; + let mut sim = StateVec::new(3); + + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1), qid(2)]); + sim.h(&[qid(0)]); + sim.h(&[qid(1)]); + + for (round, round_detection) in + round_detections.iter_mut().enumerate().take(num_rounds) + { + sim.h(&[qid(2)]); + sim.cx(&[(qid(2), qid(0))]); + sim.rz(Angle64::from_radians(theta), &[qid(0)]); + sim.rz(Angle64::from_radians(theta), &[qid(2)]); + sim.cx(&[(qid(2), qid(1))]); + sim.rz(Angle64::from_radians(theta), &[qid(1)]); + sim.rz(Angle64::from_radians(theta), &[qid(2)]); + sim.h(&[qid(2)]); + let r = sim.mz(&[qid(2)]); + if r[0].outcome { + *round_detection += 1; + } + if round < num_rounds - 1 { + sim.pz(&[qid(2)]); + } + } + } + + let sv_rates: Vec = round_detections + .iter() + .map(|&d| u64_to_f64(d) / f64::from(num_shots)) + .collect(); + + eprintln!("\nrounds={num_rounds}, theta={theta}:"); + for r in 0..num_rounds { + let se = (sv_rates[r] * (1.0 - sv_rates[r]) / f64::from(num_shots)).sqrt(); + let ratio = if sv_rates[r] > 1e-10 { + eeg_probs[r] / sv_rates[r] + } else { + f64::NAN + }; + eprintln!( + " D{r}: EEG={:.6} SV={:.6}+/-{:.6} ratio={:.4}", + eeg_probs[r], sv_rates[r], se, ratio + ); + } + } + } +} + +/// Z-basis: data in |00>, Z-check measures Z0*Z1. Coherent RZ noise. +/// Z errors commute with Z measurements, so the X-propagated components matter. +#[test] +#[ignore = "benchmark sweep; run manually with --ignored --nocapture"] +fn bench_z_basis_check() { + let num_shots = 200_000; + + eprintln!("\n=== Z-basis parity check (CX syndrome extraction) ==="); + eprintln!( + "{:>8} {:>10} {:>10} {:>10} {:>10}", + "theta", "EEG", "StateVec", "SV_stderr", "EEG/SV" + ); + + // Z-check: CX(0,2), CX(1,2), MZ(2). Ancilla 2 measures Z0*Z1 parity. + // For |00>: Z0Z1|00> = +|00>, deterministic 0. + // RZ noise creates Z errors which don't flip Z-checks directly, + // but the CX propagation can create cross-terms. + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::CX, &[0, 2]), + gate(GateType::CX, &[1, 2]), + gate(GateType::MZ, &[2]), + ]; + + for &theta in &[0.01, 0.05, 0.1, 0.2, 0.3] { + let expanded = expand::expand_circuit(&gates); + let noise = NoiseModel::coherent_only(theta); + let result = analyze_expanded(&expanded.gates, &noise); + + let aux = expanded.measurement_qubit[0]; + let det = Detector { + id: 0, + stabilizer: Bm::z(aux), + }; + let gates_pre = &gates[..gates.len() - 1]; + let stab_group = StabilizerGroup::from_circuit(gates_pre, expanded.num_original_qubits); + let entries = + build_dem_with_stabilizers(&result.generators, &[det], &[], Some(&stab_group)); + let eeg_prob: f64 = entries.iter().map(|e| e.probability).sum(); + + // StateVec + let mut det_count = 0u64; + let mut sim = StateVec::new(3); + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1), qid(2)]); + sim.cx(&[(qid(0), qid(2))]); + sim.rz(Angle64::from_radians(theta), &[qid(0)]); + sim.rz(Angle64::from_radians(theta), &[qid(2)]); + sim.cx(&[(qid(1), qid(2))]); + sim.rz(Angle64::from_radians(theta), &[qid(1)]); + sim.rz(Angle64::from_radians(theta), &[qid(2)]); + let r = sim.mz(&[qid(2)]); + if r[0].outcome { + det_count += 1; + } + } + + let sv = u64_to_f64(det_count) / f64::from(num_shots); + let se = (sv * (1.0 - sv) / f64::from(num_shots)).sqrt(); + let ratio = if sv > 1e-10 { eeg_prob / sv } else { f64::NAN }; + + eprintln!("{theta:>8.3} {eeg_prob:>10.6} {sv:>10.6} {se:>10.6} {ratio:>10.4}"); + } +} + +// ============================================================ +// Repetition code comparison: EEG vs Heisenberg vs StateVec +// ============================================================ + +/// Build an X-check repetition code circuit. +/// +/// d data qubits, d-1 ancillas measuring `X_i` * X_{i+1} using +/// H-CX-CX-H on ancilla (sensitive to Z errors from coherent RZ noise). +/// `num_rounds` syndrome extraction rounds with reset. +/// Returns (gates, `num_qubits`, ancilla indices). +fn build_repetition_code(d: usize, num_rounds: usize) -> (Vec, usize, Vec) { + let num_data = d; + let num_ancilla = d - 1; + let num_qubits = num_data + num_ancilla; + + // Ancilla i checks X_{i} * X_{i+1}, located at qubit index d + i + let ancilla_start = num_data; + + let mut gates = Vec::new(); + + // Initialize all qubits + for q in 0..num_qubits { + gates.push(gate(GateType::PZ, &[q])); + } + + for round in 0..num_rounds { + // X-check: H(anc), CX(anc, data_i), CX(anc, data_{i+1}), H(anc), MZ(anc) + for i in 0..num_ancilla { + gates.push(gate(GateType::H, &[ancilla_start + i])); + } + for i in 0..num_ancilla { + let anc = ancilla_start + i; + gates.push(gate(GateType::CX, &[anc, i])); + } + for i in 0..num_ancilla { + let anc = ancilla_start + i; + gates.push(gate(GateType::CX, &[anc, i + 1])); + } + for i in 0..num_ancilla { + gates.push(gate(GateType::H, &[ancilla_start + i])); + } + + // Measure ancillas + for i in 0..num_ancilla { + gates.push(gate(GateType::MZ, &[ancilla_start + i])); + } + + // Reset ancillas (except last round) + if round < num_rounds - 1 { + for i in 0..num_ancilla { + gates.push(gate(GateType::PZ, &[ancilla_start + i])); + } + } + } + + // Final data readout + for q in 0..num_data { + gates.push(gate(GateType::MZ, &[q])); + } + + let ancillas: Vec = (0..num_ancilla).map(|i| ancilla_start + i).collect(); + (gates, num_qubits, ancillas) +} + +/// Repetition code: compare EEG (forward), Heisenberg (backward), and `StateVec`. +#[test] +#[ignore = "benchmark sweep; run manually with --ignored --nocapture"] +fn bench_repetition_code_comparison() { + use pecos_eeg::dem_mapping::EegConfig; + use pecos_eeg::heisenberg::heisenberg_detection_probability_from_circuit; + + let num_shots = 500_000; + let theta = 0.05; + + eprintln!("\n=== Repetition code: EEG vs Heisenberg vs StateVec ==="); + eprintln!("theta = {theta}, shots = {num_shots}"); + + for &d in &[3, 5] { + for &num_rounds in &[2, 3] { + let (gates, num_qubits, ancillas) = build_repetition_code(d, num_rounds); + let num_ancilla = ancillas.len(); + + // Measurement record layout: round 0 ancillas, round 1 ancillas, ..., data readout + // Round comparison detectors: meas[round*num_ancilla + i] XOR meas[(round+1)*num_ancilla + i] + let num_detectors = num_ancilla * (num_rounds - 1); + + eprintln!( + "\n d={d}, rounds={num_rounds}, qubits={num_qubits}, detectors={num_detectors}" + ); + + // --- EEG forward --- + let expanded = expand::expand_circuit(&gates); + let noise_model = NoiseModel::coherent_only(theta); + let noise_spec = UniformNoise::coherent_only(theta); + let result = analyze_expanded(&expanded.gates, &noise_model); + + let mut dets = Vec::new(); + for round in 0..(num_rounds - 1) { + for i in 0..num_ancilla { + let m1 = round * num_ancilla + i; + let m2 = (round + 1) * num_ancilla + i; + let aux1 = expanded.measurement_qubit[m1]; + let aux2 = expanded.measurement_qubit[m2]; + let mut stab = Bm::default(); + stab.z_bits.set_bit(aux1); + stab.z_bits.set_bit(aux2); + dets.push(Detector { + id: dets.len(), + stabilizer: stab, + }); + } + } + + let exp_pre: Vec<_> = { + let last = expanded + .gates + .iter() + .rposition(|g| g.gate_type != GateType::MZ) + .unwrap(); + expanded.gates[..=last].to_vec() + }; + let stab_group = StabilizerGroup::from_circuit(&exp_pre, expanded.num_qubits); + + let entries = pecos_eeg::dem_mapping::build_dem_configured( + &result.generators, + &dets, + &[], + Some(&stab_group), + &EegConfig::default(), + ); + + let mut eeg_probs = vec![0.0; num_detectors]; + for e in &entries { + for &det_id in &e.event.detectors { + if det_id < num_detectors { + eeg_probs[det_id] += e.probability; + } + } + } + + // --- Heisenberg backward --- + let mut heis_probs = vec![0.0; num_detectors]; + for round in 0..(num_rounds - 1) { + for i in 0..num_ancilla { + let det_idx = round * num_ancilla + i; + let m1 = round * num_ancilla + i; + let m2 = (round + 1) * num_ancilla + i; + heis_probs[det_idx] = heisenberg_detection_probability_from_circuit( + &gates, + &[m1, m2], + &noise_spec, + num_qubits, + 1e-12, + ); + } + } + + // --- StateVec simulation --- + let mut sv_counts = vec![0u64; num_detectors]; + let mut sim = StateVec::new(num_qubits); + + for _ in 0..num_shots { + // Initialize + let all_qubits: Vec<_> = (0..num_qubits).map(qid).collect(); + sim.pz(&all_qubits); + + let mut meas_outcomes = Vec::new(); + + for round in 0..num_rounds { + // X-check: H(anc), CX(anc, data_i), CX(anc, data_{i+1}), H(anc) + let anc_qubits: Vec<_> = ancillas.iter().map(|&a| qid(a)).collect(); + sim.h(&anc_qubits); + + for (i, &anc) in ancillas.iter().enumerate().take(num_ancilla) { + sim.cx(&[(qid(anc), qid(i))]); + sim.rz(Angle64::from_radians(theta), &[qid(anc)]); + sim.rz(Angle64::from_radians(theta), &[qid(i)]); + } + for (i, &anc) in ancillas.iter().enumerate().take(num_ancilla) { + sim.cx(&[(qid(anc), qid(i + 1))]); + sim.rz(Angle64::from_radians(theta), &[qid(anc)]); + sim.rz(Angle64::from_radians(theta), &[qid(i + 1)]); + } + + sim.h(&anc_qubits); + + // Measure ancillas + for &anc in ancillas.iter().take(num_ancilla) { + let r = sim.mz(&[qid(anc)]); + meas_outcomes.push(r[0].outcome); + } + + // Reset (except last round) + if round < num_rounds - 1 { + sim.pz(&anc_qubits); + } + } + + // Count detector firings (round comparison) + for round in 0..(num_rounds - 1) { + for i in 0..num_ancilla { + let m1 = round * num_ancilla + i; + let m2 = (round + 1) * num_ancilla + i; + if meas_outcomes[m1] != meas_outcomes[m2] { + sv_counts[round * num_ancilla + i] += 1; + } + } + } + } + + // Print comparison + eprintln!( + " {:>6} {:>10} {:>10} {:>10} {:>10} {:>10}", + "Det", "EEG", "Heisen", "StateVec", "SV_err", "H/SV" + ); + for det_idx in 0..num_detectors { + let sv_rate = u64_to_f64(sv_counts[det_idx]) / f64::from(num_shots); + let sv_err = (sv_rate * (1.0 - sv_rate) / f64::from(num_shots)).sqrt(); + let ratio = if sv_rate > 1e-10 { + heis_probs[det_idx] / sv_rate + } else { + f64::NAN + }; + let round = det_idx / num_ancilla; + let anc = det_idx % num_ancilla; + eprintln!( + " R{round}A{anc} {:>10.6} {:>10.6} {:>10.6} {:>10.6} {:>10.4}", + eeg_probs[det_idx], heis_probs[det_idx], sv_rate, sv_err, ratio + ); + } + } + } +} + +/// KEY DIAGNOSTIC: Compare original-circuit `StateVec`, expanded-circuit `StateVec`, +/// and Heisenberg for the simplest failing case (weight-2, 2 rounds, 3 qubits). +/// +/// If expanded SV matches original SV: expansion is correct, Heisenberg has a bug. +/// If expanded SV differs: expansion is wrong. +#[test] +#[ignore = "benchmark sweep; run manually with --ignored --nocapture"] +fn bench_expansion_equivalence() { + use pecos_eeg::heisenberg::heisenberg_detection_probability_from_circuit; + + let num_shots = 1_000_000; + let theta = 0.05; + + eprintln!("\n=== Expansion equivalence: 3 qubits, 2 rounds ==="); + + // Original circuit with mid-circuit measurement + let gates_orig = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + ]; + + // Expand the circuit + let expanded = expand::expand_circuit(&gates_orig); + eprintln!( + "Expanded: {} gates, {} qubits", + expanded.gates.len(), + expanded.num_qubits + ); + eprintln!("Measurement map: {:?}", expanded.measurement_qubit); + for (i, g) in expanded.gates.iter().enumerate() { + let qs: Vec = g.qubits.iter().map(pecos_core::QubitId::index).collect(); + eprintln!(" [{i:2}] {:?}({qs:?})", g.gate_type); + } + + // --- Heisenberg on expanded circuit --- + let noise = UniformNoise::coherent_only(theta); + let h_p = heisenberg_detection_probability_from_circuit(&gates_orig, &[0, 1], &noise, 3, 0.0); + + // --- StateVec on ORIGINAL circuit (with mid-circuit measurements) --- + let mut orig_det = 0u64; + { + let mut sim = StateVec::new(3); + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1), qid(2)]); + let mut outs = [false; 2]; + for (r, out) in outs.iter_mut().enumerate() { + sim.h(&[qid(2)]); + sim.cx(&[(qid(2), qid(0))]); + sim.rz(Angle64::from_radians(theta), &[qid(2)]); + sim.rz(Angle64::from_radians(theta), &[qid(0)]); + sim.cx(&[(qid(2), qid(1))]); + sim.rz(Angle64::from_radians(theta), &[qid(1)]); + sim.rz(Angle64::from_radians(theta), &[qid(2)]); + sim.h(&[qid(2)]); + *out = sim.mz(&[qid(2)])[0].outcome; + if r == 0 { + sim.pz(&[qid(2)]); + } + } + if outs[0] != outs[1] { + orig_det += 1; + } + } + } + let sv_orig = u64_to_f64(orig_det) / f64::from(num_shots); + + // --- StateVec on EXPANDED circuit (no mid-circuit measurements) --- + let mut exp_det = 0u64; + { + let num_exp_q = expanded.num_qubits; + let mut sim = StateVec::new(num_exp_q); + for _ in 0..num_shots { + // Execute the expanded circuit gate by gate + let all_q: Vec<_> = (0..num_exp_q).map(qid).collect(); + sim.pz(&all_q); + + for (i, g) in expanded.gates.iter().enumerate() { + let qs: Vec = g.qubits.iter().map(pecos_core::QubitId::index).collect(); + + // Skip expansion gates for noise (same logic as Heisenberg) + let is_exp_gate = { + let is_qalloc = g.gate_type == pecos_core::gate_type::GateType::QAlloc; + let is_exp_cx = i > 0 + && g.gate_type == pecos_core::gate_type::GateType::CX + && expanded.gates[i - 1].gate_type + == pecos_core::gate_type::GateType::QAlloc + && expanded.gates[i - 1].qubits[0].index() + == qs.get(1).copied().unwrap_or(999); + let is_exp_pz = i > 1 + && g.gate_type == pecos_core::gate_type::GateType::PZ + && expanded.gates[i - 1].gate_type == pecos_core::gate_type::GateType::CX + && expanded.gates[i - 2].gate_type + == pecos_core::gate_type::GateType::QAlloc; + is_qalloc || is_exp_cx || is_exp_pz + }; + + match g.gate_type { + pecos_core::gate_type::GateType::PZ + | pecos_core::gate_type::GateType::QAlloc => { + for &q in &qs { + sim.pz(&[qid(q)]); + } + } + pecos_core::gate_type::GateType::H => { + for &q in &qs { + sim.h(&[qid(q)]); + } + } + pecos_core::gate_type::GateType::CX if qs.len() >= 2 => { + sim.cx(&[(qid(qs[0]), qid(qs[1]))]); + } + _ => {} + } + + // Add noise after non-expansion CX gates + if !is_exp_gate + && (g.gate_type == pecos_core::gate_type::GateType::CX) + && qs.len() >= 2 + { + sim.rz(Angle64::from_radians(theta), &[qid(qs[0])]); + sim.rz(Angle64::from_radians(theta), &[qid(qs[1])]); + } + } + + // Measure the two aux qubits + let aux0 = expanded.measurement_qubit[0]; + let aux1 = expanded.measurement_qubit[1]; + let r0 = sim.mz(&[qid(aux0)])[0].outcome; + let r1 = sim.mz(&[qid(aux1)])[0].outcome; + if r0 != r1 { + exp_det += 1; + } + } + } + let sv_exp = u64_to_f64(exp_det) / f64::from(num_shots); + + // Exact analytical + let exact = (2.0 - (6.0 * theta).cos() - (2.0 * theta).cos()) / 4.0; + + let se_orig = (sv_orig * (1.0 - sv_orig) / f64::from(num_shots)).sqrt(); + let se_exp = (sv_exp * (1.0 - sv_exp) / f64::from(num_shots)).sqrt(); + + eprintln!("\nResults:"); + eprintln!(" Exact analytical: {exact:.6}"); + eprintln!(" SV original circuit: {sv_orig:.6} +/- {se_orig:.6}"); + eprintln!(" SV expanded circuit: {sv_exp:.6} +/- {se_exp:.6}"); + eprintln!(" Heisenberg: {h_p:.6}"); + eprintln!(" H/Exact = {:.4}", h_p / exact); + eprintln!(" SVexp/SVorig = {:.4}", sv_exp / sv_orig); +} + +/// Ground truth: compute the backward Heisenberg via DIRECT MATRIX MULTIPLICATION +/// on the expanded circuit. This bypasses the Pauli-tracking backward walk entirely. +/// +/// The detection probability is: +/// p = (1 - <0...0| `O_backward` |0...0>) / 2 +/// where `O_backward` = `E_1`† ... `E_n†(D)` +/// +/// We compute `O_backward` as a 2^n × 2^n matrix by multiplying the adjoint +/// of each gate/noise channel, then evaluate the diagonal element. +#[test] +#[ignore = "benchmark sweep; run manually with --ignored --nocapture"] +fn bench_matrix_heisenberg() { + use pecos_eeg::heisenberg::heisenberg_detection_probability_from_circuit; + + let theta = 0.05; + let n = 5; // qubits: q0,q1 data, q2 ancilla, q3 aux R1, q4 aux R2 + let dim = 1 << n; // 32 + + // The expanded circuit gates (from bench_expansion_equivalence) + let gates_orig = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + ]; + let expanded = expand::expand_circuit(&gates_orig); + + // Build the detector matrix: Z_3 * Z_4 + // Z_q has eigenvalue +1 for |0> and -1 for |1> + let mut obs = vec![0.0f64; dim * dim]; // real part (obs is Hermitian diagonal for Pauli Z) + for i in 0..dim { + let bit3 = (i >> 3) & 1; + let bit4 = (i >> 4) & 1; + let z3 = if bit3 == 0 { 1.0 } else { -1.0 }; + let z4 = if bit4 == 0 { 1.0 } else { -1.0 }; + obs[i * dim + i] = z3 * z4; + } + + // Now apply the adjoint of each gate/noise channel in REVERSE order. + // For unitary U: O → U† O U + // For PZ_q (reset): O → <0_q| O |0_q> tensored with I_q (extract q=0 block) + // For noise RZ(θ): O → RZ†(θ) O RZ(θ) (unitary conjugation) + // For MZ_q: O → project to Z eigenstates (diagonal on q) + + // Helper: build a gate matrix for the full n-qubit space + // We work with real+imaginary pairs: obs_re[i*dim+j], obs_im[i*dim+j] + let mut obs_re = vec![0.0f64; dim * dim]; + let mut obs_im = vec![0.0f64; dim * dim]; + for i in 0..dim { + let bit3 = (i >> 3) & 1; + let bit4 = (i >> 4) & 1; + obs_re[i * dim + i] = if bit3 == bit4 { 1.0 } else { -1.0 }; + } + + // Process gates in reverse order + let exp_gates_set = { + let mut s = std::collections::HashSet::new(); + // Detect expansion gates (same logic as heisenberg.rs) + for i in 1..expanded.gates.len() { + if expanded.gates[i].gate_type == pecos_core::gate_type::GateType::QAlloc { + s.insert(i); + } + if expanded.gates[i].gate_type == pecos_core::gate_type::GateType::CX + && expanded.gates[i - 1].gate_type == pecos_core::gate_type::GateType::QAlloc + { + let aq = expanded.gates[i - 1].qubits[0].index(); + if expanded.gates[i].qubits.len() >= 2 && expanded.gates[i].qubits[1].index() == aq + { + s.insert(i); + if i + 1 < expanded.gates.len() + && expanded.gates[i + 1].gate_type == pecos_core::gate_type::GateType::PZ + && expanded.gates[i + 1].qubits[0].index() + == expanded.gates[i].qubits[0].index() + { + s.insert(i + 1); + } + } + } + } + s + }; + + for idx in (0..expanded.gates.len()).rev() { + let g = &expanded.gates[idx]; + let qs: Vec = g.qubits.iter().map(pecos_core::QubitId::index).collect(); + + // Apply noise adjoint (if not expansion gate) + if !exp_gates_set.contains(&idx) && g.gate_type == pecos_core::gate_type::GateType::CX { + // idle_rz on both qubits + for &q in &qs { + apply_rz_adjoint(&mut obs_re, &mut obs_im, q, theta, n); + } + } + + // Apply gate adjoint + match g.gate_type { + pecos_core::gate_type::GateType::PZ | pecos_core::gate_type::GateType::QAlloc => { + // PZ†(O) = <0_q| O |0_q> ⊗ I_q + // This zeros all matrix elements where q is in state |1> + // and copies q=0 block to the full matrix + apply_pz_adjoint(&mut obs_re, &mut obs_im, qs[0], n); + } + pecos_core::gate_type::GateType::MZ => { + // MZ†(O) = Σ_m |m> { + apply_h_adjoint(&mut obs_re, &mut obs_im, qs[0], n); + } + pecos_core::gate_type::GateType::CX => { + apply_cx_adjoint(&mut obs_re, &mut obs_im, qs[0], qs[1], n); + } + _ => {} + } + } + + // Evaluate <0...0| O_backward |0...0> + let expectation = obs_re[0]; // |0...0> is index 0 + let matrix_detection = 0.5 * (1.0 - expectation); + + // Step-by-step matrix trace: print <0|O|0> after each gate + // Re-run with printing + { + let mut tr_re = vec![0.0f64; dim * dim]; + let mut tr_im = vec![0.0f64; dim * dim]; + for i in 0..dim { + let bit3 = (i >> 3) & 1; + let bit4 = (i >> 4) & 1; + tr_re[i * dim + i] = if bit3 == bit4 { 1.0 } else { -1.0 }; + } + + eprintln!("\n Step-by-step <0|O|0> comparison (matrix vs walk):"); + eprintln!( + " {:>4} {:>20} {:>12}", + "Gate", "Description", "Matrix<0|O|0>" + ); + + for idx in (0..expanded.gates.len()).rev() { + let g = &expanded.gates[idx]; + let qs: Vec = g.qubits.iter().map(pecos_core::QubitId::index).collect(); + let is_exp = exp_gates_set.contains(&idx); + + if !is_exp && g.gate_type == pecos_core::gate_type::GateType::CX && qs.len() >= 2 { + for &q in &qs { + apply_rz_adjoint(&mut tr_re, &mut tr_im, q, theta, n); + } + } + + match g.gate_type { + pecos_core::gate_type::GateType::PZ | pecos_core::gate_type::GateType::QAlloc => { + apply_pz_adjoint(&mut tr_re, &mut tr_im, qs[0], n); + } + pecos_core::gate_type::GateType::MZ => { + apply_mz_adjoint(&mut tr_re, &mut tr_im, qs[0], n); + } + pecos_core::gate_type::GateType::H => { + apply_h_adjoint(&mut tr_re, &mut tr_im, qs[0], n); + } + pecos_core::gate_type::GateType::CX => { + apply_cx_adjoint(&mut tr_re, &mut tr_im, qs[0], qs[1], n); + } + _ => {} + } + + let e = tr_re[0]; + let tag = if is_exp { " [EXP]" } else { "" }; + eprintln!(" [{idx:>2}] {:?}({qs:?}){tag}: {e:.10}", g.gate_type); + } + } + + // Heisenberg backward walk + let noise = UniformNoise::coherent_only(theta); + let h_p = heisenberg_detection_probability_from_circuit(&gates_orig, &[0, 1], &noise, 3, 0.0); + + // Exact analytical + let exact = (2.0 - (6.0 * theta).cos() - (2.0 * theta).cos()) / 4.0; + + eprintln!("\n=== Matrix Heisenberg ground truth ==="); + eprintln!(" Exact analytical: {exact:.10}"); + eprintln!(" Matrix Heisenberg: {matrix_detection:.10}"); + eprintln!(" Backward walk: {h_p:.10}"); + eprintln!(" Matrix/Exact: {:.6}", matrix_detection / exact); + eprintln!(" Walk/Matrix: {:.6}", h_p / matrix_detection); +} + +// Matrix helpers for n-qubit system +fn apply_rz_adjoint(re: &mut [f64], im: &mut [f64], q: usize, theta: f64, n: usize) { + let dim = 1 << n; + // For each matrix element O[i,j]: + // New O[i,j] = e^{i(b_i - b_j)θ/2} · O[i,j] + // where b_i = bit q of i (0 → phase -θ/2, 1 → phase +θ/2) + for i in 0..dim { + let bi = bit_to_f64((i >> q) & 1); // 0 or 1 + for j in 0..dim { + let bj = bit_to_f64((j >> q) & 1); + let phase = (bi - bj) * theta; // phase angle + if phase.abs() < 1e-20 { + continue; + } + let cp = phase.cos(); + let sp = phase.sin(); + let idx = i * dim + j; + let r = re[idx]; + let m = im[idx]; + re[idx] = cp * r - sp * m; + im[idx] = sp * r + cp * m; + } + } +} + +fn apply_pz_adjoint(re: &mut [f64], im: &mut [f64], q: usize, n: usize) { + // PZ†(O) = Σ_m |m⟩⟨0| O |0⟩⟨m| where K_m = |0⟩⟨m| + // Matrix elements: [PZ†(O)]_{ij} = δ(i_q, j_q) · O_{(i with q=0), (j with q=0)} + // Off-diagonal elements (where qubit q differs) are ZERO. + let dim = 1 << n; + let mask = 1 << q; + for i in 0..dim { + let iq = (i >> q) & 1; + for j in 0..dim { + let jq = (j >> q) & 1; + let idx = i * dim + j; + if iq == jq { + let i0 = i & !mask; + let j0 = j & !mask; + let idx0 = i0 * dim + j0; + re[idx] = re[idx0]; + im[idx] = im[idx0]; + } else { + re[idx] = 0.0; + im[idx] = 0.0; + } + } + } +} + +fn apply_mz_adjoint(re: &mut [f64], im: &mut [f64], q: usize, n: usize) { + let dim = 1 << n; + for i in 0..dim { + let bi = (i >> q) & 1; + for j in 0..dim { + let bj = (j >> q) & 1; + if bi != bj { + let idx = i * dim + j; + re[idx] = 0.0; + im[idx] = 0.0; + } + } + } +} + +fn apply_h_adjoint(re: &mut [f64], im: &mut [f64], q: usize, n: usize) { + // H† O H = H O H (H is self-adjoint) + // H|0> = (|0>+|1>)/√2, H|1> = (|0>-|1>)/√2 + let dim = 1 << n; + let mask = 1 << q; + // For each pair (i, i^mask), apply the 2x2 Hadamard conjugation + // O' = H ⊗ I · O · H ⊗ I + // This swaps/combines rows and columns corresponding to bit q + let mut new_re = vec![0.0; dim * dim]; + let mut new_im = vec![0.0; dim * dim]; + for i in 0..dim { + for j in 0..dim { + // new O[i,j] = Σ_{a,b} H[i_q,a] O[i_with_a, j_with_b] H[b, j_q] + let i0 = i & !mask; + let i1 = i | mask; + let j0 = j & !mask; + let j1 = j | mask; + let iq = (i >> q) & 1; + let jq = (j >> q) & 1; + // H[0,0]=1/√2, H[0,1]=1/√2, H[1,0]=1/√2, H[1,1]=-1/√2 + // H[x,y] = (1/√2)(-1)^{xy} + // H[iq,a]*H[b,jq] = (1/2)(-1)^{iq*a+b*jq} + let mut sum_r = 0.0; + let mut sum_i = 0.0; + for a in 0..2usize { + for b in 0..2usize { + let ia = if a == 0 { i0 } else { i1 }; + let jb = if b == 0 { j0 } else { j1 }; + let idx = ia * dim + jb; + let sign = if (iq * a + b * jq).is_multiple_of(2) { + 1.0 + } else { + -1.0 + }; + let c = 0.5 * sign; + sum_r += c * re[idx]; + sum_i += c * im[idx]; + } + } + new_re[i * dim + j] = sum_r; + new_im[i * dim + j] = sum_i; + } + } + re.copy_from_slice(&new_re); + im.copy_from_slice(&new_im); +} + +fn apply_cx_adjoint(re: &mut [f64], im: &mut [f64], control: usize, target: usize, n: usize) { + // CX† O CX = CX O CX (CX is self-adjoint) + // CX flips target when control=1: CX|c,t> = |c, c⊕t> + let dim = 1 << n; + let cmask = 1 << control; + let tmask = 1 << target; + // CX permutation: state index i maps to i ^ (tmask if control bit set) + let cx_perm = |i: usize| -> usize { if (i & cmask) != 0 { i ^ tmask } else { i } }; + // O' = CX · O · CX: new O[i,j] = O[CX(i), CX(j)] + let mut new_re = vec![0.0; dim * dim]; + let mut new_im = vec![0.0; dim * dim]; + for i in 0..dim { + let ci = cx_perm(i); + for j in 0..dim { + let cj = cx_perm(j); + new_re[i * dim + j] = re[ci * dim + cj]; + new_im[i * dim + j] = im[ci * dim + cj]; + } + } + re.copy_from_slice(&new_re); + im.copy_from_slice(&new_im); +} + +/// Per-noise-source attribution: which noise sources does the backward walk miss? +/// +/// Enable one noise source at a time and compare matrix vs backward walk. +#[test] +#[ignore = "benchmark sweep; run manually with --ignored --nocapture"] +fn bench_per_noise_attribution() { + use pecos_eeg::heisenberg::heisenberg_detection_probability_from_circuit; + + let theta = 0.05; + let n = 5; + let dim = 1 << n; + + let gates_orig = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + ]; + let expanded = expand::expand_circuit(&gates_orig); + + // Noise source locations in expanded circuit: + // Gate 4: CX(2,0) R1 → noise on q2 and q0 + // Gate 5: CX(2,1) R1 → noise on q2 and q1 + // Gate 12: CX(2,0) R2 → noise on q2 and q0 + // Gate 13: CX(2,1) R2 → noise on q2 and q1 + let noise_sources = [ + (4, 2, "R1 CX(2,0) Z2"), + (4, 0, "R1 CX(2,0) Z0"), + (5, 2, "R1 CX(2,1) Z2"), + (5, 1, "R1 CX(2,1) Z1"), + (12, 2, "R2 CX(2,0) Z2"), + (12, 0, "R2 CX(2,0) Z0"), + (13, 2, "R2 CX(2,1) Z2"), + (13, 1, "R2 CX(2,1) Z1"), + ]; + + eprintln!("\n=== Per-noise-source attribution ==="); + eprintln!( + "{:>25} {:>12} {:>12} {:>8}", + "Source", "Matrix", "Walk", "Ratio" + ); + + for &(gate_idx, qubit, label) in &noise_sources { + // Matrix computation with only this one noise source + let mut obs_re = vec![0.0f64; dim * dim]; + let mut obs_im = vec![0.0f64; dim * dim]; + for i in 0..dim { + let bit3 = (i >> 3) & 1; + let bit4 = (i >> 4) & 1; + obs_re[i * dim + i] = if bit3 == bit4 { 1.0 } else { -1.0 }; + } + + // Process gates in reverse, only applying noise for the specified source + for idx in (0..expanded.gates.len()).rev() { + let g = &expanded.gates[idx]; + let qs: Vec = g.qubits.iter().map(pecos_core::QubitId::index).collect(); + + // Noise: only the specified source + if idx == gate_idx { + apply_rz_adjoint(&mut obs_re, &mut obs_im, qubit, theta, n); + } + + // Gate adjoint (always) + match g.gate_type { + pecos_core::gate_type::GateType::PZ | pecos_core::gate_type::GateType::QAlloc => { + apply_pz_adjoint(&mut obs_re, &mut obs_im, qs[0], n); + } + pecos_core::gate_type::GateType::MZ => { + apply_mz_adjoint(&mut obs_re, &mut obs_im, qs[0], n); + } + pecos_core::gate_type::GateType::H => { + apply_h_adjoint(&mut obs_re, &mut obs_im, qs[0], n); + } + pecos_core::gate_type::GateType::CX => { + apply_cx_adjoint(&mut obs_re, &mut obs_im, qs[0], qs[1], n); + } + _ => {} + } + } + + let matrix_p = 0.5 * (1.0 - obs_re[0]); + + // Backward walk: use a custom noise spec that only injects at the specified source + // (We can't easily do this with the public API, so just report the matrix result.) + eprintln!("{label:>25} {matrix_p:>12.8} {:>12} {:>8}", "-", "-"); + } + + // Also show all-noise results + let mut obs_re = vec![0.0f64; dim * dim]; + let mut obs_im = vec![0.0f64; dim * dim]; + for i in 0..dim { + let bit3 = (i >> 3) & 1; + let bit4 = (i >> 4) & 1; + obs_re[i * dim + i] = if bit3 == bit4 { 1.0 } else { -1.0 }; + } + for idx in (0..expanded.gates.len()).rev() { + let g = &expanded.gates[idx]; + let qs: Vec = g.qubits.iter().map(pecos_core::QubitId::index).collect(); + if g.gate_type == pecos_core::gate_type::GateType::CX && qs.len() >= 2 { + // Check if expansion gate + let is_exp = idx > 0 + && expanded.gates[idx - 1].gate_type == pecos_core::gate_type::GateType::QAlloc + && expanded.gates[idx - 1].qubits[0].index() == qs[1]; + if !is_exp { + apply_rz_adjoint(&mut obs_re, &mut obs_im, qs[0], theta, n); + apply_rz_adjoint(&mut obs_re, &mut obs_im, qs[1], theta, n); + } + } + match g.gate_type { + pecos_core::gate_type::GateType::PZ | pecos_core::gate_type::GateType::QAlloc => { + apply_pz_adjoint(&mut obs_re, &mut obs_im, qs[0], n); + } + pecos_core::gate_type::GateType::MZ => { + apply_mz_adjoint(&mut obs_re, &mut obs_im, qs[0], n); + } + pecos_core::gate_type::GateType::H => { + apply_h_adjoint(&mut obs_re, &mut obs_im, qs[0], n); + } + pecos_core::gate_type::GateType::CX => { + apply_cx_adjoint(&mut obs_re, &mut obs_im, qs[0], qs[1], n); + } + _ => {} + } + } + let matrix_all = 0.5 * (1.0 - obs_re[0]); + let noise = UniformNoise::coherent_only(theta); + let walk_all = + heisenberg_detection_probability_from_circuit(&gates_orig, &[0, 1], &noise, 3, 0.0); + eprintln!( + "{:>25} {:>12.8} {:>12.8} {:>8.4}", + "ALL", + matrix_all, + walk_all, + walk_all / matrix_all + ); +} + +/// Isolate weight-2 vs weight-4 X-check, single vs multi-round. +#[test] +#[ignore = "benchmark sweep; run manually with --ignored --nocapture"] +fn bench_weight_isolation() { + use pecos_eeg::heisenberg::heisenberg_detection_probability_from_circuit; + + let num_shots = 500_000; + let theta = 0.05; + + eprintln!("\n=== Weight isolation: single ancilla, no shared qubits ==="); + eprintln!("theta = {theta}, shots = {num_shots}\n"); + + // ---- Weight-2, 1 round: H(2), CX(2,0), CX(2,1), H(2), MZ(2) ---- + { + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + ]; + let noise = UniformNoise::coherent_only(theta); + let h_p = heisenberg_detection_probability_from_circuit(&gates, &[0], &noise, 3, 0.0); + + let mut det = 0u64; + let mut sim = StateVec::new(3); + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1), qid(2)]); + sim.h(&[qid(2)]); + sim.cx(&[(qid(2), qid(0))]); + sim.rz(Angle64::from_radians(theta), &[qid(2)]); + sim.rz(Angle64::from_radians(theta), &[qid(0)]); + sim.cx(&[(qid(2), qid(1))]); + sim.rz(Angle64::from_radians(theta), &[qid(1)]); + sim.rz(Angle64::from_radians(theta), &[qid(2)]); + sim.h(&[qid(2)]); + if sim.mz(&[qid(2)])[0].outcome { + det += 1; + } + } + let sv = u64_to_f64(det) / f64::from(num_shots); + let se = (sv * (1.0 - sv) / f64::from(num_shots)).sqrt(); + eprintln!( + "Wt-2 1rnd: H={h_p:.6} SV={sv:.6}+/-{se:.6} H/SV={:.4}", + if sv > 1e-10 { h_p / sv } else { f64::NAN } + ); + } + + // ---- Weight-2, 2 rounds: round comparison ---- + { + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + gate(GateType::PZ, &[2]), + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + ]; + let noise = UniformNoise::coherent_only(theta); + let h_p = heisenberg_detection_probability_from_circuit(&gates, &[0, 1], &noise, 3, 0.0); + + let mut det = 0u64; + let mut sim = StateVec::new(3); + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1), qid(2)]); + let mut outs = [false; 2]; + for (r, out) in outs.iter_mut().enumerate() { + sim.h(&[qid(2)]); + sim.cx(&[(qid(2), qid(0))]); + sim.rz(Angle64::from_radians(theta), &[qid(2)]); + sim.rz(Angle64::from_radians(theta), &[qid(0)]); + sim.cx(&[(qid(2), qid(1))]); + sim.rz(Angle64::from_radians(theta), &[qid(1)]); + sim.rz(Angle64::from_radians(theta), &[qid(2)]); + sim.h(&[qid(2)]); + *out = sim.mz(&[qid(2)])[0].outcome; + if r == 0 { + sim.pz(&[qid(2)]); + } + } + if outs[0] != outs[1] { + det += 1; + } + } + let sv = u64_to_f64(det) / f64::from(num_shots); + let se = (sv * (1.0 - sv) / f64::from(num_shots)).sqrt(); + eprintln!( + "Wt-2 2rnd: H={h_p:.6} SV={sv:.6}+/-{se:.6} H/SV={:.4}", + if sv > 1e-10 { h_p / sv } else { f64::NAN } + ); + } + + // ---- Weight-4, 1 round ---- + { + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::PZ, &[3]), + gate(GateType::PZ, &[4]), + gate(GateType::H, &[4]), + gate(GateType::CX, &[4, 0]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[4, 2]), + gate(GateType::CX, &[4, 3]), + gate(GateType::H, &[4]), + gate(GateType::MZ, &[4]), + ]; + let noise = UniformNoise::coherent_only(theta); + let h_p = heisenberg_detection_probability_from_circuit(&gates, &[0], &noise, 5, 0.0); + + let mut det = 0u64; + let mut sim = StateVec::new(5); + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1), qid(2), qid(3), qid(4)]); + sim.h(&[qid(4)]); + for &d in &[0usize, 1, 2, 3] { + sim.cx(&[(qid(4), qid(d))]); + sim.rz(Angle64::from_radians(theta), &[qid(4)]); + sim.rz(Angle64::from_radians(theta), &[qid(d)]); + } + sim.h(&[qid(4)]); + if sim.mz(&[qid(4)])[0].outcome { + det += 1; + } + } + let sv = u64_to_f64(det) / f64::from(num_shots); + let se = (sv * (1.0 - sv) / f64::from(num_shots)).sqrt(); + eprintln!( + "Wt-4 1rnd: H={h_p:.6} SV={sv:.6}+/-{se:.6} H/SV={:.4}", + if sv > 1e-10 { h_p / sv } else { f64::NAN } + ); + } + + // ---- Weight-4, 2 rounds ---- + { + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::PZ, &[3]), + gate(GateType::PZ, &[4]), + gate(GateType::H, &[4]), + gate(GateType::CX, &[4, 0]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[4, 2]), + gate(GateType::CX, &[4, 3]), + gate(GateType::H, &[4]), + gate(GateType::MZ, &[4]), + gate(GateType::PZ, &[4]), + gate(GateType::H, &[4]), + gate(GateType::CX, &[4, 0]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[4, 2]), + gate(GateType::CX, &[4, 3]), + gate(GateType::H, &[4]), + gate(GateType::MZ, &[4]), + ]; + let noise = UniformNoise::coherent_only(theta); + let h_p = heisenberg_detection_probability_from_circuit(&gates, &[0, 1], &noise, 5, 0.0); + + let mut det = 0u64; + let mut sim = StateVec::new(5); + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1), qid(2), qid(3), qid(4)]); + let mut outs = [false; 2]; + for (r, out) in outs.iter_mut().enumerate() { + sim.h(&[qid(4)]); + for &d in &[0usize, 1, 2, 3] { + sim.cx(&[(qid(4), qid(d))]); + sim.rz(Angle64::from_radians(theta), &[qid(4)]); + sim.rz(Angle64::from_radians(theta), &[qid(d)]); + } + sim.h(&[qid(4)]); + *out = sim.mz(&[qid(4)])[0].outcome; + if r == 0 { + sim.pz(&[qid(4)]); + } + } + if outs[0] != outs[1] { + det += 1; + } + } + let sv = u64_to_f64(det) / f64::from(num_shots); + let se = (sv * (1.0 - sv) / f64::from(num_shots)).sqrt(); + eprintln!( + "Wt-4 2rnd: H={h_p:.6} SV={sv:.6}+/-{se:.6} H/SV={:.4}", + if sv > 1e-10 { h_p / sv } else { f64::NAN } + ); + } + + eprintln!(); + // ---- 2 weight-2 ancillas sharing a data qubit, 2 rounds ---- + { + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + gate(GateType::PZ, &[3]), + gate(GateType::PZ, &[4]), + // Round 1 + gate(GateType::H, &[3]), + gate(GateType::H, &[4]), + gate(GateType::CX, &[3, 0]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[3, 1]), + gate(GateType::CX, &[4, 2]), + gate(GateType::H, &[3]), + gate(GateType::H, &[4]), + gate(GateType::MZ, &[3]), + gate(GateType::MZ, &[4]), + gate(GateType::PZ, &[3]), + gate(GateType::PZ, &[4]), + // Round 2 + gate(GateType::H, &[3]), + gate(GateType::H, &[4]), + gate(GateType::CX, &[3, 0]), + gate(GateType::CX, &[4, 1]), + gate(GateType::CX, &[3, 1]), + gate(GateType::CX, &[4, 2]), + gate(GateType::H, &[3]), + gate(GateType::H, &[4]), + gate(GateType::MZ, &[3]), + gate(GateType::MZ, &[4]), + ]; + let noise = UniformNoise::coherent_only(theta); + let h_a0 = heisenberg_detection_probability_from_circuit(&gates, &[0, 2], &noise, 5, 0.0); + let h_a1 = heisenberg_detection_probability_from_circuit(&gates, &[1, 3], &noise, 5, 0.0); + + let mut a0 = 0u64; + let mut a1 = 0u64; + let mut sim = StateVec::new(5); + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1), qid(2), qid(3), qid(4)]); + let mut outs = [false; 4]; // [r0a0, r0a1, r1a0, r1a1] + for (r, out_pair) in outs.chunks_mut(2).enumerate() { + sim.h(&[qid(3), qid(4)]); + sim.cx(&[(qid(3), qid(0))]); + sim.rz(Angle64::from_radians(theta), &[qid(3)]); + sim.rz(Angle64::from_radians(theta), &[qid(0)]); + sim.cx(&[(qid(4), qid(1))]); + sim.rz(Angle64::from_radians(theta), &[qid(4)]); + sim.rz(Angle64::from_radians(theta), &[qid(1)]); + sim.cx(&[(qid(3), qid(1))]); + sim.rz(Angle64::from_radians(theta), &[qid(3)]); + sim.rz(Angle64::from_radians(theta), &[qid(1)]); + sim.cx(&[(qid(4), qid(2))]); + sim.rz(Angle64::from_radians(theta), &[qid(4)]); + sim.rz(Angle64::from_radians(theta), &[qid(2)]); + sim.h(&[qid(3), qid(4)]); + out_pair[0] = sim.mz(&[qid(3)])[0].outcome; + out_pair[1] = sim.mz(&[qid(4)])[0].outcome; + if r == 0 { + sim.pz(&[qid(3), qid(4)]); + } + } + if outs[0] != outs[2] { + a0 += 1; + } + if outs[1] != outs[3] { + a1 += 1; + } + } + let sv0 = u64_to_f64(a0) / f64::from(num_shots); + let sv1 = u64_to_f64(a1) / f64::from(num_shots); + let se0 = (sv0 * (1.0 - sv0) / f64::from(num_shots)).sqrt(); + let se1 = (sv1 * (1.0 - sv1) / f64::from(num_shots)).sqrt(); + eprintln!( + "Shared A0: H={h_a0:.6} SV={sv0:.6}+/-{se0:.6} H/SV={:.4}", + if sv0 > 1e-10 { h_a0 / sv0 } else { f64::NAN } + ); + eprintln!( + "Shared A1: H={h_a1:.6} SV={sv1:.6}+/-{se1:.6} H/SV={:.4}", + if sv1 > 1e-10 { h_a1 / sv1 } else { f64::NAN } + ); + } +} + +/// Measure how Heisenberg backward walk cost scales with surface code distance and rounds. +/// +/// The backward walk creates 2^m terms where m is the number of anticommuting +/// noise sources per detector. For larger codes, m grows, making the walk +/// exponentially more expensive. +/// +/// We measure ALL round-comparison detectors and report per-detector timing, +/// since boundary detectors (ancilla 0/last) only couple to 2 CX gates while +/// bulk detectors (middle ancillas) couple to 4, seeing more noise sources. +#[test] +#[ignore = "benchmark sweep; run manually with --ignored --nocapture"] +fn bench_heisenberg_scaling() { + use pecos_eeg::heisenberg::heisenberg_detection_probability_from_circuit; + use std::time::Instant; + + let theta = 0.05; + + // --- Part 1: Vary distance at fixed rounds=2 --- + eprintln!("\n=== Heisenberg scaling: distance sweep (rounds=2, all detectors) ==="); + eprintln!( + "{:>4} {:>10} {:>14} {:>6} {:>18} {:>12} {:>12} {:>12}", + "d", "num_qubits", "expanded_q", "n_det", "max_prob", "max_ms", "total_ms", "per_det_ms" + ); + + for &d in &[3, 5, 7, 9] { + let num_rounds = 2; + let (gates, num_qubits, _ancillas) = build_repetition_code(d, num_rounds); + let num_ancilla = d - 1; + let num_detectors = num_ancilla; // rounds-1 == 1 comparison per ancilla + + let expanded = expand::expand_circuit(&gates); + let noise = UniformNoise::coherent_only(theta); + + let mut max_prob = 0.0f64; + let mut max_ms = 0.0f64; + let mut total_ms = 0.0f64; + + eprintln!(" d={d} per-detector detail:"); + eprintln!(" {:>6} {:>18} {:>12}", "det", "prob", "time_ms"); + + for i in 0..num_detectors { + // Round comparison: meas record i (round 0) vs i + num_ancilla (round 1) + let m1 = i; + let m2 = i + num_ancilla; + + let start = Instant::now(); + let prob = heisenberg_detection_probability_from_circuit( + &gates, + &[m1, m2], + &noise, + num_qubits, + 0.0, + ); + let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0; + + eprintln!(" {i:>6} {prob:>18.10} {elapsed_ms:>12.2}"); + + max_prob = max_prob.max(prob); + max_ms = max_ms.max(elapsed_ms); + total_ms += elapsed_ms; + } + + let per_det = total_ms / usize_to_f64(num_detectors); + eprintln!( + "{d:>4} {num_qubits:>10} {:>14} {num_detectors:>6} {max_prob:>18.10} {max_ms:>12.2} {total_ms:>12.2} {per_det:>12.2}", + expanded.num_qubits + ); + } + + // --- Part 2: Vary rounds at fixed d=3 --- + eprintln!("\n=== Heisenberg scaling: rounds sweep (d=3, all detectors) ==="); + eprintln!( + "{:>6} {:>10} {:>14} {:>6} {:>18} {:>12} {:>12} {:>12}", + "rounds", + "num_qubits", + "expanded_q", + "n_det", + "max_prob", + "max_ms", + "total_ms", + "per_det_ms" + ); + + for &num_rounds in &[2, 3, 4, 5] { + let d = 3; + let (gates, num_qubits, _ancillas) = build_repetition_code(d, num_rounds); + let num_ancilla = d - 1; + // Round comparison detectors: (num_rounds - 1) comparisons per ancilla + let num_detectors = num_ancilla * (num_rounds - 1); + + let expanded = expand::expand_circuit(&gates); + let noise = UniformNoise::coherent_only(theta); + + let mut max_prob = 0.0f64; + let mut max_ms = 0.0f64; + let mut total_ms = 0.0f64; + + eprintln!(" rounds={num_rounds} per-detector detail:"); + eprintln!(" {:>6} {:>18} {:>12}", "det", "prob", "time_ms"); + + for round in 0..(num_rounds - 1) { + for i in 0..num_ancilla { + let m1 = round * num_ancilla + i; + let m2 = (round + 1) * num_ancilla + i; + + let start = Instant::now(); + let prob = heisenberg_detection_probability_from_circuit( + &gates, + &[m1, m2], + &noise, + num_qubits, + 0.0, + ); + let elapsed_ms = start.elapsed().as_secs_f64() * 1000.0; + + eprintln!(" R{round}A{i} {prob:>18.10} {elapsed_ms:>12.2}"); + + max_prob = max_prob.max(prob); + max_ms = max_ms.max(elapsed_ms); + total_ms += elapsed_ms; + } + } + + let per_det = total_ms / usize_to_f64(num_detectors); + eprintln!( + "{num_rounds:>6} {num_qubits:>10} {:>14} {num_detectors:>6} {max_prob:>18.10} {max_ms:>12.2} {total_ms:>12.2} {per_det:>12.2}", + expanded.num_qubits + ); + } +} + +/// Combined coherent + stochastic noise on a Wt-2 2-round X-check circuit. +/// +/// Uses `idle_rz=0.05` (coherent RZ after each CX) plus `p_meas=0.003` +/// (measurement bit-flip). The Heisenberg walk handles both H-type and +/// S-type generators in a single backward pass. +/// +/// `StateVec` applies identical noise: RZ(theta) on both qubits after each +/// CX, and flips the MZ outcome with probability `p_meas`. +/// +/// Detector: round-comparison (meas[0] XOR meas[1]). +#[test] +#[ignore = "benchmark sweep; run manually with --ignored --nocapture"] +fn bench_combined_noise() { + use pecos_eeg::heisenberg::heisenberg_detection_probability_from_circuit; + use pecos_random::PecosRng; + use pecos_random::rng_ext::RngProbabilityExt; + + let idle_rz = 0.05; + let p_meas = 0.003; + let num_shots = 500_000; + + eprintln!("\n=== Combined coherent + stochastic noise: Wt-2 2-round X-check ==="); + eprintln!("idle_rz = {idle_rz}, p_meas = {p_meas}, shots = {num_shots}\n"); + + // ---- Build the circuit: 2 data (q0,q1) + 1 ancilla (q2), 2 rounds ---- + let gates = vec![ + gate(GateType::PZ, &[0]), + gate(GateType::PZ, &[1]), + gate(GateType::PZ, &[2]), + // Round 1 + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + gate(GateType::PZ, &[2]), + // Round 2 + gate(GateType::H, &[2]), + gate(GateType::CX, &[2, 0]), + gate(GateType::CX, &[2, 1]), + gate(GateType::H, &[2]), + gate(GateType::MZ, &[2]), + ]; + + // ---- Heisenberg walk with combined noise ---- + let noise = UniformNoise { + idle_rz, + p1: 0.0, + p2: 0.0, + p_meas, + p_prep: 0.0, + }; + // Detector = Z on meas[0] * Z on meas[1] (round comparison) + let h_p = heisenberg_detection_probability_from_circuit(&gates, &[0, 1], &noise, 3, 0.0); + + // ---- Also compute coherent-only and meas-only for decomposition ---- + let noise_coh = UniformNoise::coherent_only(idle_rz); + let h_coh = heisenberg_detection_probability_from_circuit(&gates, &[0, 1], &noise_coh, 3, 0.0); + + let noise_meas = UniformNoise { + idle_rz: 0.0, + p1: 0.0, + p2: 0.0, + p_meas, + p_prep: 0.0, + }; + let h_meas = + heisenberg_detection_probability_from_circuit(&gates, &[0, 1], &noise_meas, 3, 0.0); + + // ---- StateVec simulation with matching noise ---- + let mut rng = PecosRng::seed_from_u64(12345); + let meas_threshold = rng.probability_threshold(p_meas); + + let mut det = 0u64; + let mut sim = StateVec::new(3); + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1), qid(2)]); + let mut outs = [false; 2]; + for (r, out_slot) in outs.iter_mut().enumerate() { + sim.h(&[qid(2)]); + // CX(2,0) + idle RZ noise + sim.cx(&[(qid(2), qid(0))]); + sim.rz(Angle64::from_radians(idle_rz), &[qid(2)]); + sim.rz(Angle64::from_radians(idle_rz), &[qid(0)]); + // CX(2,1) + idle RZ noise + sim.cx(&[(qid(2), qid(1))]); + sim.rz(Angle64::from_radians(idle_rz), &[qid(1)]); + sim.rz(Angle64::from_radians(idle_rz), &[qid(2)]); + sim.h(&[qid(2)]); + // MZ with measurement error + let mut outcome = sim.mz(&[qid(2)])[0].outcome; + if rng.check_probability(meas_threshold) { + outcome = !outcome; + } + *out_slot = outcome; + if r == 0 { + sim.pz(&[qid(2)]); + } + } + if outs[0] != outs[1] { + det += 1; + } + } + let sv = u64_to_f64(det) / f64::from(num_shots); + let se = (sv * (1.0 - sv) / f64::from(num_shots)).sqrt(); + let ratio = if sv > 1e-10 { h_p / sv } else { f64::NAN }; + + eprintln!("Heisenberg (combined): {h_p:.6}"); + eprintln!("Heisenberg (coh only): {h_coh:.6}"); + eprintln!("Heisenberg (meas only):{h_meas:.6}"); + eprintln!("StateVec: {sv:.6} +/- {se:.6}"); + eprintln!("H/SV ratio: {ratio:.4}"); + eprintln!(); + + // ---- Sweep p_meas to see how combined noise scales ---- + eprintln!( + "{:>8} {:>10} {:>10} {:>10} {:>10}", + "p_meas", "H_comb", "SV", "SV_stderr", "H/SV" + ); + + for &pm in &[0.0, 0.001, 0.003, 0.005, 0.01, 0.02, 0.05] { + let n = UniformNoise { + idle_rz, + p1: 0.0, + p2: 0.0, + p_meas: pm, + p_prep: 0.0, + }; + let hp = heisenberg_detection_probability_from_circuit(&gates, &[0, 1], &n, 3, 0.0); + + let pm_threshold = rng.probability_threshold(pm); + let mut d = 0u64; + for _ in 0..num_shots { + sim.pz(&[qid(0), qid(1), qid(2)]); + let mut os = [false; 2]; + for (r, out_slot) in os.iter_mut().enumerate() { + sim.h(&[qid(2)]); + sim.cx(&[(qid(2), qid(0))]); + sim.rz(Angle64::from_radians(idle_rz), &[qid(2)]); + sim.rz(Angle64::from_radians(idle_rz), &[qid(0)]); + sim.cx(&[(qid(2), qid(1))]); + sim.rz(Angle64::from_radians(idle_rz), &[qid(1)]); + sim.rz(Angle64::from_radians(idle_rz), &[qid(2)]); + sim.h(&[qid(2)]); + let mut out = sim.mz(&[qid(2)])[0].outcome; + if rng.check_probability(pm_threshold) { + out = !out; + } + *out_slot = out; + if r == 0 { + sim.pz(&[qid(2)]); + } + } + if os[0] != os[1] { + d += 1; + } + } + let s = u64_to_f64(d) / f64::from(num_shots); + let e = (s * (1.0 - s) / f64::from(num_shots)).sqrt(); + let r = if s > 1e-10 { hp / s } else { f64::NAN }; + eprintln!("{pm:>8.4} {hp:>10.6} {s:>10.6} {e:>10.6} {r:>10.4}"); + } +} diff --git a/exp/pecos-eeg/tests/strong_sim_validation.rs b/exp/pecos-eeg/tests/strong_sim_validation.rs new file mode 100644 index 000000000..b6e3434c0 --- /dev/null +++ b/exp/pecos-eeg/tests/strong_sim_validation.rs @@ -0,0 +1,279 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Validation tests for approximate strong simulation. + +use pecos_eeg::Bm; +use pecos_eeg::circuit::PropagatedEeg; +use pecos_eeg::eeg::EegType; +use pecos_eeg::strong_sim::outcome_probability; + +/// H-type correction: |0⟩ with `H_X` gives p(1) = h² at leading order. +/// Cross-check: exact p(1) = sin²(h). +#[test] +fn test_h_correction_matches_exact() { + let stabs = vec![Bm::z(0)]; + + for &h in &[0.01, 0.05, 0.1, 0.2] { + let gens = vec![PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: h, + source: None, + }]; + + let p1 = outcome_probability(&gens, &[true], &stabs); + let exact = h.sin().powi(2); + + // EEG gives h², which ≈ sin²(h) for small h + assert!( + (p1.total - h * h).abs() < 1e-10, + "h={h}: EEG p(1)={:.6} expected h²={:.6}", + p1.total, + h * h + ); + + // Check closeness to exact + let rel_err = (p1.total - exact).abs() / exact; + eprintln!( + "h={h:.2}: EEG={:.6} exact={exact:.6} rel_err={rel_err:.4}", + p1.total + ); + if h <= 0.1 { + assert!(rel_err < 0.02, "h={h}: relative error {rel_err:.4} > 2%"); + } + } +} + +/// Probability conservation: p(0) + p(1) = 1 at leading order for H-type. +#[test] +fn test_h_probability_conservation() { + let stabs = vec![Bm::z(0)]; + let h = 0.1; + + let gens = vec![PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: h, + source: None, + }]; + + let p0 = outcome_probability(&gens, &[false], &stabs); + let p1 = outcome_probability(&gens, &[true], &stabs); + let sum = p0.total + p1.total; + + assert!( + (sum - 1.0).abs() < 0.001, + "p(0)+p(1) = {sum:.6}, expected ≈ 1.0" + ); +} + +/// Bell state: H_{Z0} noise should NOT affect Z-basis measurement probabilities. +/// (Z commutes with Z-basis measurements.) +#[test] +fn test_bell_h_z_invisible() { + let stabs = vec![ + Bm::x(0).multiply(&Bm::x(1)), // XX + Bm::z(0).multiply(&Bm::z(1)), // ZZ + ]; + + let gens = vec![PropagatedEeg { + eeg_type: EegType::H, + label: Bm::z(0), + label2: None, + coeff: 0.1, + source: None, + }]; + + // Z₀ has no X component → no bit flips → α(S_Z) = 0 for all outcomes + // H correction should be zero for all outcomes + for outcome in &[ + vec![false, false], + vec![true, true], + vec![false, true], + vec![true, false], + ] { + let p = outcome_probability(&gens, outcome, &stabs); + assert!( + p.h_correction.abs() < 1e-10, + "H_Z should be invisible: outcome={outcome:?} h_corr={}", + p.h_correction + ); + } +} + +/// Bell state: H_{X0} shifts probability between {00,11} and {01,10}. +#[test] +fn test_bell_h_x_shifts() { + let stabs = vec![ + Bm::x(0).multiply(&Bm::x(1)), // XX + Bm::z(0).multiply(&Bm::z(1)), // ZZ + ]; + let h = 0.05; + + let gens = vec![PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: h, + source: None, + }]; + + let p00 = outcome_probability(&gens, &[false, false], &stabs); + let p11 = outcome_probability(&gens, &[true, true], &stabs); + let p01 = outcome_probability(&gens, &[false, true], &stabs); + let p10 = outcome_probability(&gens, &[true, false], &stabs); + + eprintln!( + "Bell + H_X0: p00={:.6} p11={:.6} p01={:.6} p10={:.6}", + p00.total, p11.total, p01.total, p10.total + ); + + // X0 flips qubit 0: maps {00,11} ↔ {10,01} + // H_X creates probability at {01,10} from {00,11} + // p(01) and p(10) should be > 0 (increased from noiseless 0) + assert!(p01.h_correction > 0.0, "p(01) should increase"); + assert!(p10.h_correction > 0.0, "p(10) should increase"); + + // p(00) and p(11) should decrease + assert!(p00.h_correction < 0.0, "p(00) should decrease"); + assert!(p11.h_correction < 0.0, "p(11) should decrease"); + + // Conservation: total ≈ 1 + let sum = p00.total + p11.total + p01.total + p10.total; + assert!((sum - 1.0).abs() < 0.01, "Conservation: sum={sum:.6}"); + + // Symmetry: p(01) = p(10) (X0 on symmetric Bell state) + assert!( + (p01.total - p10.total).abs() < 1e-10, + "Symmetry: p01={:.6} p10={:.6}", + p01.total, + p10.total + ); +} + +/// Multiple H generators: verify the off-diagonal Φ correctly accounts +/// for cross-generator interference. +#[test] +fn test_two_h_generators_interference() { + // |0⟩ with H_X rate h1 and H_Y rate h2 + let stabs = vec![Bm::z(0)]; + let h1 = 0.05; + let h2 = 0.03; + + let gens = vec![ + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::x(0), + label2: None, + coeff: h1, + source: None, + }, + PropagatedEeg { + eeg_type: EegType::H, + label: Bm::y(0), + label2: None, + coeff: h2, + source: None, + }, + ]; + + let p0 = outcome_probability(&gens, &[false], &stabs); + let p1 = outcome_probability(&gens, &[true], &stabs); + + // Diagonal: h1² + h2² for p(1) + let diagonal = h1 * h1 + h2 * h2; + + // X and Y both flip (both have X component set). + // Diagonal H·H: both contribute +h² to p(1). + // Off-diagonal: X·Y = iZ (anticommuting), so C_{X,Y} has α contribution + // from the off-diagonal Φ computation. + eprintln!( + "Two H gens: p0={:.6} p1={:.6} diagonal={diagonal:.6}", + p0.total, p1.total + ); + + // p(1) should be at least the diagonal + assert!( + p1.total >= diagonal * 0.9, + "p(1)={:.6} should be ≥ diagonal {diagonal:.6}", + p1.total + ); + + // Conservation + assert!((p0.total + p1.total - 1.0).abs() < 0.01); +} + +/// C-type first-order α: directly construct a C generator and verify. +#[test] +fn test_c_type_alpha() { + // |0⟩ with C_{X,X} = 2S_X. Should give same result as S_X at double rate. + let stabs = vec![Bm::z(0)]; + let c = 0.005; + + let gens = vec![PropagatedEeg { + eeg_type: EegType::C, + label: Bm::x(0), + label2: Some(Bm::x(0)), + coeff: c, + source: None, + }]; + + let p0 = outcome_probability(&gens, &[false], &stabs); + let p1 = outcome_probability(&gens, &[true], &stabs); + + // C_{X,X} = 2S_X. α(C_{X,X}) at outcome 0: + // Φ(X,X) at 0 = 0 (flipped not in support), Φ(I,I) = 1. + // Φ(XX,I) = Φ(I,I) = 1. + // α = 2*Re(0) - Re(1 + 1) = -2. + // Correction: c * α = 0.005 * (-2) = -0.01. + // But wait — with the scale factor for pure state (ζ=0): scale=1. + // So ca_correction = 1 * 0.005 * (-2) = -0.01. + // p(0) = 1 + (-0.01) = 0.99. + + eprintln!("C-type: p0={:.6} p1={:.6}", p0.total, p1.total); + assert!( + (p0.total - 0.99).abs() < 0.02, + "p(0) ≈ 0.99: got {:.6}", + p0.total + ); + assert!(p1.total > 0.0, "p(1) should be positive"); +} + +/// A-type α: construct an A generator. For stabilizer states, A-type +/// typically gives zero (requires iQ1Q2 to be stabilizer eigenvalue). +#[test] +fn test_a_type_alpha_zero_for_stabilizer() { + // |0⟩ with A_{X,Z}: iXZ = iY. Is iY|0⟩ = ±|0⟩? Y|0⟩ = i|1⟩, so iY|0⟩ = -|1⟩. + // Not an eigenstate → α(A) should be 0 for both outcomes. + let stabs = vec![Bm::z(0)]; + + let gens = vec![PropagatedEeg { + eeg_type: EegType::A, + label: Bm::x(0), + label2: Some(Bm::z(0)), + coeff: 0.01, + source: None, + }]; + + let p0 = outcome_probability(&gens, &[false], &stabs); + let p1 = outcome_probability(&gens, &[true], &stabs); + + // A-type uses Im(Φ). For |0⟩ (real stabilizer state), Φ values + // should be real, so Im = 0, giving zero A-type correction. + eprintln!( + "A-type: p0_corr={:.8} p1_corr={:.8}", + p0.s_correction + p0.h_correction, + p1.s_correction + p1.h_correction + ); + // The ca_correction is part of total but not separately exposed. + // Just check total is unchanged from noiseless. + assert!( + (p0.total - 1.0).abs() < 1e-6, + "A on |0⟩ should not change p(0)" + ); + assert!(p1.total.abs() < 1e-6, "A on |0⟩ should not change p(1)"); +} diff --git a/exp/pecos-eeg/tests/surface_code.rs b/exp/pecos-eeg/tests/surface_code.rs new file mode 100644 index 000000000..cfb30fc0b --- /dev/null +++ b/exp/pecos-eeg/tests/surface_code.rs @@ -0,0 +1,190 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Integration test: EEG analysis on a repetition code circuit. + +use pecos_core::Gate; +use pecos_eeg::Bm; +use pecos_eeg::circuit::{NoiseModel, analyze_expanded}; +use pecos_eeg::dem_mapping::*; +use pecos_eeg::eeg::EegType; +use pecos_eeg::expand; +use pecos_quantum::TickCircuit; + +/// Build a 3-qubit repetition code with 2 syndrome rounds. +/// +/// Layout: data qubits 0,1,2; ancilla qubits 3,4 +/// Stabilizers: Z0Z1 (measured by ancilla 3), Z1Z2 (measured by ancilla 4) +fn build_repetition_code() -> (Vec, Vec, Vec) { + let mut tc = TickCircuit::new(); + + // Initialize all qubits + tc.tick().pz(&[0, 1, 2, 3, 4]); + + // Two syndrome extraction rounds + for _round in 0..2 { + tc.tick().pz(&[3, 4]); + tc.tick().cx(&[(0, 3), (1, 4)]); + tc.tick().cx(&[(1, 3), (2, 4)]); + tc.tick().mz(&[3, 4]); + } + + // Final data readout + tc.tick().mz(&[0, 1, 2]); + + let gates: Vec = tc + .iter_gate_batches() + .map(|batch| batch.as_gate().clone()) + .collect(); + + // Detector stabilizers: X on ancilla qubit (anticommutes with Z errors + // that propagate through CX from data qubits). + let detectors = vec![ + Detector { + id: 0, + stabilizer: Bm::x(3), + }, + Detector { + id: 1, + stabilizer: Bm::x(4), + }, + ]; + + let observables = vec![Observable { + id: 0, + pauli: Bm::x(0).multiply(&Bm::x(1)).multiply(&Bm::x(2)), + }]; + + (gates, detectors, observables) +} + +#[test] +fn test_repetition_code_no_noise() { + let (gates, _, _) = build_repetition_code(); + let expanded = expand::expand_circuit(&gates); + let noise = NoiseModel::coherent_only(0.0); + let result = analyze_expanded(&expanded.gates, &noise); + + assert!(result.generators.is_empty()); +} + +#[test] +fn test_repetition_code_coherent_noise() { + let (gates, detectors, observables) = build_repetition_code(); + let expanded = expand::expand_circuit(&gates); + let noise = NoiseModel::coherent_only(0.1); + let result = analyze_expanded(&expanded.gates, &noise); + + let h_count = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::H) + .count(); + assert!(h_count > 0, "Should have H generators from RZ noise"); + + let dem_entries = build_dem(&result.generators, &detectors, &observables); + + assert!( + !dem_entries.is_empty(), + "Coherent noise should produce detection events" + ); + + let dem_str = format_dem(&dem_entries); + eprintln!("Coherent DEM:\n{dem_str}"); + + for entry in &dem_entries { + assert!(entry.probability > 0.0, "Probability must be positive"); + assert!( + entry.probability < 1.0, + "Probability {:.6} too large", + entry.probability + ); + } +} + +#[test] +fn test_repetition_code_depolarizing_noise() { + let (gates, detectors, observables) = build_repetition_code(); + let expanded = expand::expand_circuit(&gates); + let noise = NoiseModel::depolarizing(0.003); + let result = analyze_expanded(&expanded.gates, &noise); + + let s_count = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::S) + .count(); + assert!(s_count > 0); + + let dem_entries = build_dem(&result.generators, &detectors, &observables); + + let dem_str = format_dem(&dem_entries); + eprintln!("Stochastic DEM:\n{dem_str}"); + + assert!(!dem_entries.is_empty()); + for entry in &dem_entries { + assert!(entry.probability > 0.0); + assert!(entry.probability < 0.5); + } +} + +#[test] +fn test_repetition_code_combined_noise() { + let (gates, detectors, observables) = build_repetition_code(); + let expanded = expand::expand_circuit(&gates); + let noise = NoiseModel::depolarizing(0.003).with_idle_rz(0.1); + let result = analyze_expanded(&expanded.gates, &noise); + + let h_count = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::H) + .count(); + let s_count = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::S) + .count(); + assert!(h_count > 0); + assert!(s_count > 0); + + let dem_entries = build_dem(&result.generators, &detectors, &observables); + + let dem_str = format_dem(&dem_entries); + eprintln!("Combined DEM:\n{dem_str}"); + + assert!(!dem_entries.is_empty()); +} + +#[test] +fn test_eeg_generator_count_scales_linearly() { + for num_rounds in [1, 2, 4, 8] { + let mut tc = TickCircuit::new(); + tc.tick().pz(&[0, 1, 2, 3, 4]); + + for _ in 0..num_rounds { + tc.tick().pz(&[3, 4]); + tc.tick().cx(&[(0, 3), (1, 4)]); + tc.tick().cx(&[(1, 3), (2, 4)]); + tc.tick().mz(&[3, 4]); + } + tc.tick().mz(&[0, 1, 2]); + + let gates: Vec = tc + .iter_gate_batches() + .map(|batch| batch.as_gate().clone()) + .collect(); + let expanded = expand::expand_circuit(&gates); + let noise = NoiseModel::coherent_only(0.1); + let result = analyze_expanded(&expanded.gates, &noise); + + let h_count = result + .generators + .iter() + .filter(|g| g.eeg_type == EegType::H) + .count(); + eprintln!("Rounds={num_rounds}: {h_count} H generators"); + assert!(h_count < 1000, "Generator count should be polynomial"); + } +} diff --git a/exp/pecos-experimental/src/hugr_executor.rs b/exp/pecos-experimental/src/hugr_executor.rs index b5a792faf..c514664df 100644 --- a/exp/pecos-experimental/src/hugr_executor.rs +++ b/exp/pecos-experimental/src/hugr_executor.rs @@ -213,7 +213,8 @@ where | GateType::QFree | GateType::Idle | GateType::MeasCrosstalkGlobalPayload - | GateType::MeasCrosstalkLocalPayload => {} + | GateType::MeasCrosstalkLocalPayload + | GateType::TrackedPauliMeta => {} // Single-qubit Clifford gates GateType::X => { @@ -317,6 +318,7 @@ where | GateType::CRZ | GateType::CH | GateType::CCX + | GateType::Channel | GateType::Custom => { return Err(HugrExecutionError::UnsupportedGate { gate_type: gate.gate_type, diff --git a/exp/pecos-lindblad/Cargo.toml b/exp/pecos-lindblad/Cargo.toml new file mode 100644 index 000000000..b91b7c1ad --- /dev/null +++ b/exp/pecos-lindblad/Cargo.toml @@ -0,0 +1,33 @@ +[package] +name = "pecos-lindblad" +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 = "Lindblad-to-Pauli-Lindblad noise synthesis for PECOS" +readme = "README.md" + +[lib] +crate-type = ["rlib"] + +[features] +default = [] +serde = ["dep:serde"] + +[dependencies] +num-complex.workspace = true +pecos-quantum.workspace = true +thiserror.workspace = true +rand.workspace = true +serde = { workspace = true, optional = true } + +[dev-dependencies] +approx.workspace = true +pecos-qec.workspace = true +proptest.workspace = true +rand.workspace = true +serde_json.workspace = true diff --git a/exp/pecos-lindblad/README.md b/exp/pecos-lindblad/README.md new file mode 100644 index 000000000..6f8965f35 --- /dev/null +++ b/exp/pecos-lindblad/README.md @@ -0,0 +1,16 @@ +# pecos-lindblad + +Lindblad-to-Pauli-Lindblad noise synthesis for PECOS. + +Given a per-gate Lindbladian `{H_ideal, H_err, c_ops, tau_g}`, compute the +effective Pauli-Lindblad rates `lambda_k` that feed into +`pecos-qec::DemStabSim` or any Pauli-level noise channel. + +**Status:** experimental, Phase 1 (numerical baseline + 1Q identity test). + +**Design docs:** +- `design/lindblad_sim_skeleton.md` -- crate layout, API surface, test plan +- `design/lindblad_magnus_algorithm.md` -- math spec, closed forms, references + +**Primary reference:** Malekakhlagh et al., *Efficient Lindblad synthesis for +noise model construction*, npj QI 2025, arXiv:2502.03462. diff --git a/exp/pecos-lindblad/src/basis.rs b/exp/pecos-lindblad/src/basis.rs new file mode 100644 index 000000000..7a6f94458 --- /dev/null +++ b/exp/pecos-lindblad/src/basis.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. + +//! Pauli basis types for Lindblad -> Pauli-Lindblad synthesis. + +use std::fmt; +use std::str::FromStr; + +/// Single-qubit Pauli operator. +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +#[repr(u8)] +pub enum Pauli1 { + I = 0, + X = 1, + Y = 2, + Z = 3, +} + +impl Pauli1 { + pub fn from_char(c: char) -> Option { + match c { + 'I' | 'i' => Some(Pauli1::I), + 'X' | 'x' => Some(Pauli1::X), + 'Y' | 'y' => Some(Pauli1::Y), + 'Z' | 'z' => Some(Pauli1::Z), + _ => None, + } + } + + pub fn to_char(self) -> char { + match self { + Pauli1::I => 'I', + Pauli1::X => 'X', + Pauli1::Y => 'Y', + Pauli1::Z => 'Z', + } + } + + /// Pauli multiplication ignoring global phase. Returns the Hermitian + /// Pauli factor: XY -> Z (phase `i` dropped), etc. Safe for our use in + /// `PauliLindbladModel::sample`, where rates are carried by commuting + /// structure and phases cancel in `P rho P^dag` style actions. + pub fn multiply(self, other: Pauli1) -> Pauli1 { + use Pauli1::*; + match (self, other) { + (I, x) | (x, I) => x, + (X, X) | (Y, Y) | (Z, Z) => I, + (X, Y) | (Y, X) => Z, + (Y, Z) | (Z, Y) => X, + (X, Z) | (Z, X) => Y, + } + } + + /// 1 if two single-qubit Paulis anticommute, 0 if they commute. + pub fn anticommutes_with(self, other: Pauli1) -> u8 { + use Pauli1::*; + match (self, other) { + (I, _) | (_, I) => 0, + (a, b) if a == b => 0, + _ => 1, + } + } +} + +/// Multi-qubit Pauli string. Index 0 = leftmost factor. +#[derive(Clone, Debug, PartialEq, Eq, Hash)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PauliString(pub Vec); + +#[derive(Clone, Copy, Debug, PartialEq, Eq)] +pub struct ParsePauliStringError { + invalid_char: char, +} + +impl fmt::Display for ParsePauliStringError { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + write!(f, "invalid Pauli character {:?}", self.invalid_char) + } +} + +impl std::error::Error for ParsePauliStringError {} + +impl PauliString { + pub fn single(p: Pauli1) -> Self { + PauliString(vec![p]) + } + + pub fn from_label(s: &str) -> Option { + s.chars() + .map(Pauli1::from_char) + .collect::>>() + .map(PauliString) + } + + pub fn num_qubits(&self) -> usize { + self.0.len() + } + + /// Weight (number of non-identity factors). + pub fn weight(&self) -> usize { + self.0.iter().filter(|&&p| p != Pauli1::I).count() + } + + /// Is this the identity string? + pub fn is_identity(&self) -> bool { + self.weight() == 0 + } + + /// Elementwise product with global phase dropped. See + /// [`Pauli1::multiply`]. + pub fn multiply(&self, other: &PauliString) -> PauliString { + assert_eq!(self.num_qubits(), other.num_qubits(), "ragged multiply"); + PauliString( + self.0 + .iter() + .zip(&other.0) + .map(|(a, b)| a.multiply(*b)) + .collect(), + ) + } + + /// Symplectic product `_sp`: 1 if the two strings + /// anticommute, 0 if they commute. Equal to (sum of pairwise + /// anticommutes) mod 2. + pub fn symplectic_product(&self, other: &PauliString) -> u8 { + assert_eq!(self.num_qubits(), other.num_qubits(), "ragged symplectic"); + self.0 + .iter() + .zip(&other.0) + .map(|(a, b)| a.anticommutes_with(*b)) + .sum::() + & 1 + } + + /// Enumerate all non-identity Pauli strings on `n` qubits. Length + /// 4^n - 1 = 3, 15, 63, ... + pub fn enumerate_nonidentity(n: usize) -> Vec { + let total = 1usize << (2 * n); + (1..total) + .map(|idx| { + // idx in base 4: two bits per qubit, low bits = rightmost factor + let mut qs = Vec::with_capacity(n); + for q in 0..n { + let shift = 2 * (n - 1 - q); + let bits = (idx >> shift) & 0b11; + let p = match bits { + 0 => Pauli1::I, + 1 => Pauli1::X, + 2 => Pauli1::Y, + 3 => Pauli1::Z, + _ => unreachable!(), + }; + qs.push(p); + } + PauliString(qs) + }) + .collect() + } +} + +impl FromStr for PauliString { + type Err = ParsePauliStringError; + + fn from_str(s: &str) -> Result { + let mut paulis = Vec::with_capacity(s.len()); + for c in s.chars() { + let Some(pauli) = Pauli1::from_char(c) else { + return Err(ParsePauliStringError { invalid_char: c }); + }; + paulis.push(pauli); + } + Ok(PauliString(paulis)) + } +} + +impl fmt::Display for PauliString { + fn fmt(&self, f: &mut fmt::Formatter<'_>) -> fmt::Result { + for p in &self.0 { + write!(f, "{}", p.to_char())?; + } + Ok(()) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn roundtrip_string() { + let s = PauliString::from_label("XYZ").unwrap(); + assert_eq!(s.num_qubits(), 3); + assert_eq!(s.weight(), 3); + assert_eq!(format!("{}", s), "XYZ"); + } + + #[test] + fn identity_weight() { + let s = PauliString::from_label("III").unwrap(); + assert_eq!(s.weight(), 0); + } + + #[test] + fn mixed_weight() { + let s = PauliString::from_label("IXI").unwrap(); + assert_eq!(s.weight(), 1); + } + + #[test] + fn symplectic_product_2q() { + let ix = PauliString::from_label("IX").unwrap(); + let iz = PauliString::from_label("IZ").unwrap(); + let zx = PauliString::from_label("ZX").unwrap(); + assert_eq!(ix.symplectic_product(&iz), 1); // X,Z anticommute on right + assert_eq!(ix.symplectic_product(&ix), 0); + assert_eq!(zx.symplectic_product(&iz), 1); // X,Z on right anticommute + assert_eq!(zx.symplectic_product(&zx), 0); + } + + #[test] + fn enumerate_1q_gives_xyz() { + let all = PauliString::enumerate_nonidentity(1); + assert_eq!(all.len(), 3); + assert_eq!(all[0], PauliString::from_label("X").unwrap()); + assert_eq!(all[1], PauliString::from_label("Y").unwrap()); + assert_eq!(all[2], PauliString::from_label("Z").unwrap()); + } + + #[test] + fn enumerate_2q_gives_15() { + let all = PauliString::enumerate_nonidentity(2); + assert_eq!(all.len(), 15); + // First should be IX (idx 1 = 0b01 = 0|X). + assert_eq!(all[0], PauliString::from_label("IX").unwrap()); + // Last should be ZZ (idx 15 = 0b1111 = Z|Z). + assert_eq!(all[14], PauliString::from_label("ZZ").unwrap()); + } +} diff --git a/exp/pecos-lindblad/src/gate.rs b/exp/pecos-lindblad/src/gate.rs new file mode 100644 index 000000000..81c19cbb1 --- /dev/null +++ b/exp/pecos-lindblad/src/gate.rs @@ -0,0 +1,191 @@ +// 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. + +//! Gate type: ideal Hamiltonian + noise Lindbladian + duration. + +use num_complex::Complex64; + +use crate::lindbladian::Lindbladian; +use crate::matrix::{self, Matrix}; + +/// A physical gate with its ideal rotation, noise model, and duration. +#[derive(Clone, Debug)] +pub struct Gate { + pub label: String, + pub num_qubits: usize, + /// Noise-free part of the dynamics. Sets the interaction frame. + pub ideal: Lindbladian, + /// Noise (coherent + incoherent) applied during the gate. + pub noise: Lindbladian, + /// Gate duration in the same time units as `gamma_j` of the noise. + pub tau_g: f64, +} + +impl Gate { + /// Construct a gate from an arbitrary ideal Hamiltonian `H_g`, a noise + /// [`Lindbladian`], and a duration `tau_g`. Provides a general escape + /// hatch for gate types beyond the named constructors (e.g. iSWAP, + /// XX_theta, arbitrary SU(4)). + /// + /// The ideal Hamiltonian is passed as a `d x d` matrix where + /// `d = 2^num_qubits`. It must be Hermitian (caller's responsibility; + /// [`matrix::expm`] assumes this for unitarity). + pub fn from_hamiltonian( + label: impl Into, + num_qubits: usize, + ideal_hamiltonian: Matrix, + noise: Lindbladian, + tau_g: f64, + ) -> Self { + let d = 1usize << num_qubits; + assert_eq!(ideal_hamiltonian.len(), d * d, "ideal H wrong shape"); + assert_eq!(noise.d, d, "noise dim mismatch"); + assert!(tau_g >= 0.0, "tau_g must be non-negative, got {}", tau_g); + // Lindbladian::new checks Hermiticity of its Hamiltonian input. + let ideal = Lindbladian::new(d, ideal_hamiltonian, Vec::new()); + Self { + label: label.into(), + num_qubits, + ideal, + noise, + tau_g, + } + } + + /// Identity gate (no ideal Hamiltonian) with a given noise Lindbladian + /// and duration. + pub fn identity(num_qubits: usize, noise: Lindbladian, tau_g: f64) -> Self { + let d = 1 << num_qubits; + assert_eq!(noise.d, d, "noise dim mismatch"); + Self { + label: "I".to_string(), + num_qubits, + ideal: Lindbladian::zero(d), + noise, + tau_g, + } + } + + /// 1-qubit arbitrary-angle X rotation: `X_theta = exp(-i theta/2 X)`. + /// Parameterized by drive frequency `omega_x` and rotation angle + /// `theta`; gate duration is `theta / omega_x`. + pub fn x_theta(omega_x: f64, theta: f64, noise: Lindbladian) -> Self { + assert!(omega_x > 0.0, "omega_x must be positive"); + assert_eq!(noise.d, 2, "x_theta is 1-qubit"); + let d = 2; + // H_g = (omega_x / 2) * X + let h_g: Matrix = matrix::scale( + &matrix::pauli_1q(crate::basis::Pauli1::X), + Complex64::new(omega_x / 2.0, 0.0), + ); + let ideal = Lindbladian::new(d, h_g, Vec::new()); + let tau_g = theta / omega_x; + Self { + label: format!("X_{{{:.4}}}", theta), + num_qubits: 1, + ideal, + noise, + tau_g, + } + } + + /// 3-qubit `CX_theta ⊗ I` gate with coherent IZZ crosstalk between + /// target (qubit 1) and spectator (qubit 2). `H_g = (omega/2)(IXI - ZXI)`, + /// `H_delta = (delta/2) IZZ`, `tau_g = theta/omega`. + /// + /// The spectator qubit (q2) is untouched by the ideal gate but + /// experiences a `ZZ` interaction with the target. This is the 3Q + /// crosstalk case from arXiv:2502.03462 eqs. 1007-1011, the only + /// non-trivial 3Q case in the paper. + /// + /// Noise on this gate is **purely coherent** (zero c_ops) -- use + /// [`crate::synthesize_exact_unitary`] to synthesize Pauli-Lindblad + /// rates (the Omega_1 dissipative-noise path gives zero for coherent + /// noise). + pub fn cx_theta_with_izz_crosstalk(omega: f64, theta: f64, delta: f64) -> Self { + use crate::basis::Pauli1; + assert!(omega > 0.0, "omega must be positive"); + let d = 8; + let i2 = matrix::identity(2); + let x = matrix::pauli_1q(Pauli1::X); + let z = matrix::pauli_1q(Pauli1::Z); + // H_g = (omega / 2) * (IXI - ZXI) + let ixi = matrix::kron(&matrix::kron(&i2, &x, 2, 2), &i2, 4, 2); + let zxi = matrix::kron(&matrix::kron(&z, &x, 2, 2), &i2, 4, 2); + let diff = matrix::sub(&ixi, &zxi); + let h_g = matrix::scale(&diff, Complex64::new(omega / 2.0, 0.0)); + let ideal = Lindbladian::new(d, h_g, Vec::new()); + // H_delta = (delta / 2) * IZZ + let izz = matrix::kron(&matrix::kron(&i2, &z, 2, 2), &z, 4, 2); + let h_delta = matrix::scale(&izz, Complex64::new(delta / 2.0, 0.0)); + let noise = Lindbladian::new(d, h_delta, Vec::new()); + let tau_g = theta / omega; + Self { + label: format!("CX_{{{:.4}}}⊗I+IZZ({:.4})", theta, delta), + num_qubits: 3, + ideal, + noise, + tau_g, + } + } + + /// 2-qubit arbitrary-angle CX rotation: + /// `CX_theta = exp(-i (theta/2) (IX - ZX))`. Block-diagonal in the + /// computational basis with the top 2x2 block zero (identity action on + /// `|0l>`) and the bottom block = `omega_cx * X` (X rotation on the + /// target when control = `|1>`). + /// Reference: arXiv:2502.03462 lines 913-924. + pub fn cx_theta(omega_cx: f64, theta: f64, noise: Lindbladian) -> Self { + use crate::basis::Pauli1; + assert!(omega_cx > 0.0, "omega_cx must be positive"); + assert_eq!(noise.d, 4, "cx_theta is 2-qubit"); + let d = 4; + let i2 = matrix::identity(2); + let x = matrix::pauli_1q(Pauli1::X); + let z = matrix::pauli_1q(Pauli1::Z); + let ix = matrix::kron(&i2, &x, 2, 2); + let zx = matrix::kron(&z, &x, 2, 2); + // H_g = (omega_cx / 2) * (IX - ZX) + let diff = matrix::sub(&ix, &zx); + let h_g = matrix::scale(&diff, Complex64::new(omega_cx / 2.0, 0.0)); + let ideal = Lindbladian::new(d, h_g, Vec::new()); + let tau_g = theta / omega_cx; + Self { + label: format!("CX_{{{:.4}}}", theta), + num_qubits: 2, + ideal, + noise, + tau_g, + } + } + + /// 2-qubit arbitrary-angle CZ rotation: + /// `CZ_theta = exp(-i (theta/2) (II - IZ - ZI + ZZ))`. + /// In computational basis `H_g = diag(0, 0, 0, 2 * omega_cz)`. + /// Reference: arXiv:2502.03462 lines 885-891. + pub fn cz_theta(omega_cz: f64, theta: f64, noise: Lindbladian) -> Self { + assert!(omega_cz > 0.0, "omega_cz must be positive"); + assert_eq!(noise.d, 4, "cz_theta is 2-qubit"); + let d = 4; + let mut h_g = matrix::zeros(d); + h_g[3 * d + 3] = Complex64::new(2.0 * omega_cz, 0.0); + let ideal = Lindbladian::new(d, h_g, Vec::new()); + let tau_g = theta / omega_cz; + Self { + label: format!("CZ_{{{:.4}}}", theta), + num_qubits: 2, + ideal, + noise, + tau_g, + } + } +} diff --git a/exp/pecos-lindblad/src/lib.rs b/exp/pecos-lindblad/src/lib.rs new file mode 100644 index 000000000..4f21a533c --- /dev/null +++ b/exp/pecos-lindblad/src/lib.rs @@ -0,0 +1,104 @@ +// 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. + +//! # pecos-lindblad +//! +//! Lindblad-to-Pauli-Lindblad noise synthesis for PECOS. Given a per-gate +//! Lindbladian `{H_ideal, noise, tau_g}`, produces the effective +//! Pauli-Lindblad rates `{lambda_k}` that feed +//! [`pecos_qec::dem_stab::DemStabSim`] via +//! [`pecos_qec::fault_tolerance::dem_builder::PerGateTypeNoise`]. +//! +//! # Golden path +//! +//! ``` +//! use pecos_lindblad::{ +//! noise_models::ad_pd_1q, synthesize_identity_1q, Gate, Pauli1, PauliString, +//! }; +//! +//! // Device parameters in physical (T_1, T_2) terms. +//! let t1 = 100e-6; +//! let t2 = 80e-6; +//! let tau_g = 1e-6; +//! +//! let noise = ad_pd_1q(t1, t2); +//! let gate = Gate::identity(1, noise, tau_g); +//! let pl = synthesize_identity_1q(&gate); +//! +//! let lambda_x = pl.rate(&PauliString::single(Pauli1::X)); +//! assert!(lambda_x > 0.0); +//! ``` +//! +//! # Picking a synthesis path +//! +//! - [`synthesize_identity_1q`] -- fastest for 1-qubit identity gates +//! (closed-form, machine-precision). +//! - [`synthesize_numerical`] -- any gate with purely dissipative noise +//! (AD, PD, depolarizing). Simpson's rule on the interaction-frame +//! Lindbladian. +//! - [`synthesize_superop`] -- general: any gate, any mix of coherent +//! and dissipative noise, all orders of Magnus. Slower but correct +//! in all regimes. +//! +//! # Feature flags +//! +//! - `serde` -- (de)serialize [`PauliLindbladModel`] for caching. +//! +//! # Modules by audience +//! +//! - **Core forward synthesis**: [`Gate`], [`synthesize_identity_1q`], +//! [`synthesize_numerical`], [`synthesize_superop`]. +//! - **Noise-model verification** (diff helpers + analytic `(T_1, T_2)` +//! recovery + Monte Carlo UQ): see [`noise_models`] and +//! [`PauliLindbladModel::diff`], [`PauliLindbladModel::diagnose_gap`]. +//! - **Non-Markovian (TCL)**: see [`time_dep`] for 1/f dephasing, +//! Gaussian decay, coloured coherent noise. +//! +//! # Verified gate families (arXiv:2502.03462) +//! +//! | Gate | Paper eqs. | Constructor | +//! |---|---|---| +//! | 1Q identity + AD+PD (exact) | line 812 | [`Gate::identity`] | +//! | 1Q `X_theta` + AD+PD | 869-874 | [`Gate::x_theta`] | +//! | 2Q `CZ_theta` + AD+PD | 896-906 | [`Gate::cz_theta`] | +//! | 2Q `CX_theta` + AD+PD | 929-956 | [`Gate::cx_theta`] | +//! | 2Q coherent IZ/ZI/ZZ phase | 981, 986-990 | any `Gate` + `coherent_phase_2q` | +//! | 3Q `CX ⊗ I` + IZZ crosstalk | 1009-1011 | [`Gate::cx_theta_with_izz_crosstalk`] | +//! +//! See `design/lindblad_magnus_algorithm.md` for the math spec. + +pub mod basis; +pub mod gate; +pub mod lindbladian; +pub mod matrix; +pub mod noise_models; +pub mod pauli_lindblad; +pub mod synthesis; +pub mod time_dep; + +// Core API -- the golden path. +pub use basis::{Pauli1, PauliString}; +pub use gate::Gate; +pub use lindbladian::Lindbladian; +pub use pauli_lindblad::PauliLindbladModel; +pub use synthesis::{ + DEFAULT_N_SLICES, DEFAULT_N_STEPS, synthesize_identity_1q, synthesize_numerical, + synthesize_superop, +}; + +// Advanced synthesis paths -- specialized cases of `synthesize_superop`. +// Exposed for users who know they need the specific behavior. +pub use synthesis::{ + synthesize_exact_unitary, synthesize_numerical_1q, synthesize_superop_identity, +}; + +pub use time_dep::{HermitianFn, RateFn, TimeDepLindbladian, synthesize_superop_time_dep}; diff --git a/exp/pecos-lindblad/src/lindbladian.rs b/exp/pecos-lindblad/src/lindbladian.rs new file mode 100644 index 000000000..5093158cc --- /dev/null +++ b/exp/pecos-lindblad/src/lindbladian.rs @@ -0,0 +1,127 @@ +// 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. + +//! Lindbladian type: Hermitian Hamiltonian plus rate-weighted collapse operators. + +use num_complex::Complex64; + +use crate::matrix::{self, Matrix}; + +/// Time-independent Lindbladian of form +/// `drho/dt = -i[H, rho] + sum_j gamma_j * D[c_j] rho` +/// where `D[c] rho = c rho c^dag - 1/2 {c^dag c, rho}`. +#[derive(Clone, Debug)] +pub struct Lindbladian { + pub d: usize, + pub hamiltonian: Matrix, + pub collapse: Vec<(Matrix, f64)>, +} + +impl Lindbladian { + pub fn new(d: usize, hamiltonian: Matrix, collapse: Vec<(Matrix, f64)>) -> Self { + assert_eq!(hamiltonian.len(), d * d, "hamiltonian wrong shape"); + assert!( + matrix::is_hermitian(&hamiltonian, d, 1e-10), + "Lindbladian Hamiltonian must be Hermitian", + ); + for (c, gamma) in &collapse { + assert_eq!(c.len(), d * d, "collapse op wrong shape"); + assert!( + *gamma >= 0.0, + "collapse rate must be non-negative, got {}", + gamma + ); + } + Self { + d, + hamiltonian, + collapse, + } + } + + /// Zero Hamiltonian with no collapse ops (no-op). + pub fn zero(d: usize) -> Self { + Self { + d, + hamiltonian: matrix::zeros(d), + collapse: Vec::new(), + } + } + + /// Build the `d^2 x d^2` Liouville-superoperator matrix representation + /// of `L`. Column-stacking convention: `vec(L(rho)) = L_super * vec(rho)`. + /// + /// `L(rho) = -i[H, rho] + sum_j gamma_j * ( c_j rho c_j^dag + /// - 1/2 {c_j^dag c_j, rho} )` + /// translates to: + /// + /// `L_super = -i (I ⊗ H - H^T ⊗ I) + /// + sum_j gamma_j * ( conj(c_j) ⊗ c_j + /// - 1/2 I ⊗ c_j^dag c_j + /// - 1/2 (c_j^dag c_j)^T ⊗ I )` + /// + /// (Note `(c^dag)^T = conj(c)`.) + pub fn superoperator(&self) -> Matrix { + let d = self.d; + let d2 = d * d; + let id = matrix::identity(d); + let neg_i = Complex64::new(0.0, -1.0); + + // Hamiltonian part: -i (I ⊗ H - H^T ⊗ I). + let h_t = matrix::transpose(&self.hamiltonian, d); + let coh = matrix::sub( + &matrix::kron(&id, &self.hamiltonian, d, d), + &matrix::kron(&h_t, &id, d, d), + ); + let mut l_super = matrix::scale(&coh, neg_i); + + for (c, gamma) in &self.collapse { + let c_bar = matrix::conj(c); + let c_dag = matrix::dag(c, d); + let cdag_c = matrix::matmul(&c_dag, c, d); + let cdag_c_t = matrix::transpose(&cdag_c, d); + + // gamma * ( c_bar ⊗ c - 1/2 I ⊗ c^dag c - 1/2 (c^dag c)^T ⊗ I ) + let term_a = matrix::kron(&c_bar, c, d, d); + let term_b = matrix::kron(&id, &cdag_c, d, d); + let term_c = matrix::kron(&cdag_c_t, &id, d, d); + let inner = matrix::sub( + &term_a, + &matrix::add( + &matrix::scale(&term_b, Complex64::new(0.5, 0.0)), + &matrix::scale(&term_c, Complex64::new(0.5, 0.0)), + ), + ); + let scaled = matrix::scale(&inner, Complex64::new(*gamma, 0.0)); + l_super = matrix::add(&l_super, &scaled); + } + + assert_eq!(l_super.len(), d2 * d2); + l_super + } + + /// Apply `L` to a matrix `rho`. Returns `L(rho)`. + pub fn apply(&self, rho: &Matrix) -> Matrix { + let d = self.d; + let neg_i = Complex64::new(0.0, -1.0); + let mut out = matrix::scale(&matrix::commutator(&self.hamiltonian, rho, d), neg_i); + for (c, gamma) in &self.collapse { + let cdag = matrix::dag(c, d); + let c_rho_cdag = matrix::matmul(&matrix::matmul(c, rho, d), &cdag, d); + let cdag_c = matrix::matmul(&cdag, c, d); + let acom = matrix::anticommutator(&cdag_c, rho, d); + let diss = matrix::sub(&c_rho_cdag, &matrix::scale(&acom, Complex64::new(0.5, 0.0))); + out = matrix::add(&out, &matrix::scale(&diss, Complex64::new(*gamma, 0.0))); + } + out + } +} diff --git a/exp/pecos-lindblad/src/matrix.rs b/exp/pecos-lindblad/src/matrix.rs new file mode 100644 index 000000000..fb1773949 --- /dev/null +++ b/exp/pecos-lindblad/src/matrix.rs @@ -0,0 +1,383 @@ +// 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. + +//! Minimal dense complex-matrix helpers for Phase 1. +//! +//! Matrices are stored row-major as `Vec` of length `d*d`. Caller +//! tracks `d`. This is intentionally primitive -- swap to faer / ndarray once +//! Phase 1 numbers prove out. + +use num_complex::Complex64; + +use crate::basis::{Pauli1, PauliString}; + +pub type Matrix = Vec; + +pub fn zeros(d: usize) -> Matrix { + vec![Complex64::new(0.0, 0.0); d * d] +} + +pub fn identity(d: usize) -> Matrix { + let mut m = zeros(d); + for i in 0..d { + m[i * d + i] = Complex64::new(1.0, 0.0); + } + m +} + +pub fn matmul(a: &Matrix, b: &Matrix, d: usize) -> Matrix { + let mut c = zeros(d); + for i in 0..d { + for k in 0..d { + let aik = a[i * d + k]; + if aik == Complex64::new(0.0, 0.0) { + continue; + } + for j in 0..d { + c[i * d + j] += aik * b[k * d + j]; + } + } + } + c +} + +/// Conjugate transpose. +pub fn dag(a: &Matrix, d: usize) -> Matrix { + let mut b = zeros(d); + for i in 0..d { + for j in 0..d { + b[j * d + i] = a[i * d + j].conj(); + } + } + b +} + +/// Plain transpose (no complex conjugation). +pub fn transpose(a: &Matrix, d: usize) -> Matrix { + let mut b = zeros(d); + for i in 0..d { + for j in 0..d { + b[j * d + i] = a[i * d + j]; + } + } + b +} + +/// Element-wise complex conjugate. +pub fn conj(a: &Matrix) -> Matrix { + a.iter().map(|c| c.conj()).collect() +} + +pub fn trace(a: &Matrix, d: usize) -> Complex64 { + (0..d).map(|i| a[i * d + i]).sum() +} + +pub fn scale(a: &Matrix, s: Complex64) -> Matrix { + a.iter().map(|x| x * s).collect() +} + +pub fn add(a: &Matrix, b: &Matrix) -> Matrix { + a.iter().zip(b.iter()).map(|(x, y)| x + y).collect() +} + +pub fn sub(a: &Matrix, b: &Matrix) -> Matrix { + a.iter().zip(b.iter()).map(|(x, y)| x - y).collect() +} + +/// `A*B - B*A`. +pub fn commutator(a: &Matrix, b: &Matrix, d: usize) -> Matrix { + sub(&matmul(a, b, d), &matmul(b, a, d)) +} + +/// `A*B + B*A`. +pub fn anticommutator(a: &Matrix, b: &Matrix, d: usize) -> Matrix { + add(&matmul(a, b, d), &matmul(b, a, d)) +} + +/// 2x2 Pauli matrix for a single-qubit Pauli operator. +pub fn pauli_1q(p: Pauli1) -> Matrix { + let z = Complex64::new(0.0, 0.0); + let o = Complex64::new(1.0, 0.0); + let i = Complex64::new(0.0, 1.0); + match p { + Pauli1::I => vec![o, z, z, o], + Pauli1::X => vec![z, o, o, z], + Pauli1::Y => vec![z, -i, i, z], + Pauli1::Z => vec![o, z, z, -o], + } +} + +/// Lowering operator sigma_- = |1><0| = [[0,0],[1,0]]. +pub fn sigma_minus() -> Matrix { + let z = Complex64::new(0.0, 0.0); + let o = Complex64::new(1.0, 0.0); + vec![z, z, o, z] +} + +/// Kronecker product of `a` (da x da) and `b` (db x db). Result is +/// `(da * db) x (da * db)`. +pub fn kron(a: &Matrix, b: &Matrix, da: usize, db: usize) -> Matrix { + let d = da * db; + let mut out = zeros(d); + for i in 0..da { + for j in 0..da { + let aij = a[i * da + j]; + if aij == Complex64::new(0.0, 0.0) { + continue; + } + for k in 0..db { + for l in 0..db { + let bkl = b[k * db + l]; + let row = i * db + k; + let col = j * db + l; + out[row * d + col] = aij * bkl; + } + } + } + } + out +} + +/// Matrix representation of a multi-qubit Pauli string (tensor-product +/// of 2x2 Pauli matrices, left-to-right). +pub fn pauli_string_mat(ps: &PauliString) -> Matrix { + assert!(!ps.0.is_empty(), "empty PauliString"); + let mut acc = pauli_1q(ps.0[0]); + let mut d = 2; + for p in ps.0.iter().skip(1) { + acc = kron(&acc, &pauli_1q(*p), d, 2); + d *= 2; + } + acc +} + +/// Check whether a d x d matrix is (numerically) Hermitian: `M = M^dag`. +/// Returns true if all `|M_ij - conj(M_ji)| < tol`. +pub fn is_hermitian(m: &Matrix, d: usize, tol: f64) -> bool { + assert_eq!(m.len(), d * d, "is_hermitian: wrong shape"); + for i in 0..d { + for j in 0..d { + if (m[i * d + j] - m[j * d + i].conj()).norm() > tol { + return false; + } + } + } + true +} + +/// Check whether a d x d matrix is (numerically) diagonal. +pub fn is_diagonal(m: &Matrix, d: usize, tol: f64) -> bool { + for i in 0..d { + for j in 0..d { + if i == j { + continue; + } + if m[i * d + j].norm() > tol { + return false; + } + } + } + true +} + +/// Is a 4x4 matrix 2x2-block-diagonal? I.e. off-diagonal 2x2 blocks zero. +pub fn is_2x2_block_diagonal(m: &Matrix, tol: f64) -> bool { + assert_eq!(m.len(), 16, "is_2x2_block_diagonal requires 4x4 input"); + for r in 0..2 { + for c in 2..4 { + if m[r * 4 + c].norm() > tol { + return false; + } + if m[c * 4 + r].norm() > tol { + return false; + } + } + } + true +} + +/// `exp(-i * H * t)` for a 4x4 2x2-block-diagonal Hermitian `H`, assuming +/// each 2x2 block is traceless (true for CX_theta: blocks are `0_2` and +/// `omega * X`). +pub fn exp_minus_i_h_t_2x2_block_diag(h: &Matrix, t: f64) -> Matrix { + let d = 4; + assert_eq!(h.len(), d * d); + let mut ul = zeros(2); + let mut lr = zeros(2); + for r in 0..2 { + for c in 0..2 { + ul[r * 2 + c] = h[r * 4 + c]; + lr[r * 2 + c] = h[(r + 2) * 4 + (c + 2)]; + } + } + let ul_exp = exp_minus_i_h_t_1q_traceless(&ul, t); + let lr_exp = exp_minus_i_h_t_1q_traceless(&lr, t); + let mut out = zeros(d); + for r in 0..2 { + for c in 0..2 { + out[r * 4 + c] = ul_exp[r * 2 + c]; + out[(r + 2) * 4 + (c + 2)] = lr_exp[r * 2 + c]; + } + } + out +} + +/// `exp(-i * H * t)` for a Hermitian `H`. Dispatches: +/// - `H` diagonal -> elementwise exp (any `d`). +/// - `d == 2`, non-diagonal, traceless -> Bloch form. +/// - `d == 4`, 2x2-block-diagonal with traceless blocks -> block-wise +/// Bloch form (covers CX_theta). +/// - else falls through to general [`expm`] scaling-squaring. +pub fn exp_minus_i_h_t(h: &Matrix, d: usize, t: f64) -> Matrix { + if is_diagonal(h, d, 1e-14) { + let mut u = zeros(d); + for i in 0..d { + let arg = Complex64::new(0.0, -h[i * d + i].re * t); + u[i * d + i] = arg.exp(); + } + return u; + } + if d == 2 { + return exp_minus_i_h_t_1q_traceless(h, t); + } + if d == 4 && is_2x2_block_diagonal(h, 1e-14) { + return exp_minus_i_h_t_2x2_block_diag(h, t); + } + let arg = scale(h, Complex64::new(0.0, -t)); + expm(&arg, d) +} + +/// General matrix exponential via Taylor series + scaling + squaring. +/// +/// - Scale: find `s` such that `||A/2^s|| < 0.5` (so Taylor converges quickly). +/// - Taylor: `exp(A/2^s) ≈ sum_{k=0..=N} (A/2^s)^k / k!` with `N=20`. +/// - Squaring: `exp(A) = (exp(A/2^s))^(2^s)` via `s` matrix squarings. +/// +/// Accuracy ~machine-precision for Hermitian `A` with arbitrary norm; +/// validated in module tests against Bloch-form and diagonal paths. +pub fn expm(a: &Matrix, d: usize) -> Matrix { + let norm = inf_norm(a, d); + if norm < 1e-14 { + return identity(d); + } + // Choose s so that ||A / 2^s|| < 0.5. + let s_float = (norm / 0.5).log2().max(0.0).ceil(); + let s: u32 = s_float as u32; + let factor = Complex64::new(2f64.powi(-(s as i32)), 0.0); + let scaled = scale(a, factor); + let mut result = taylor_exp(&scaled, d, 20); + for _ in 0..s { + result = matmul(&result, &result, d); + } + result +} + +/// Taylor series of `exp(A)` truncated at degree `n`. Assumes `||A||` +/// is already small (typically `< 0.5`). +fn taylor_exp(a: &Matrix, d: usize, n: usize) -> Matrix { + let mut term = identity(d); + let mut sum = identity(d); + for k in 1..=n { + term = scale(&matmul(&term, a, d), Complex64::new(1.0 / k as f64, 0.0)); + sum = add(&sum, &term); + } + sum +} + +/// Infinity norm: max over rows of `sum_j |A_ij|`. +pub fn inf_norm(a: &Matrix, d: usize) -> f64 { + (0..d) + .map(|i| (0..d).map(|j| a[i * d + j].norm()).sum::()) + .fold(0.0, f64::max) +} + +/// Column-stack vectorization `vec(M)` of a `d x d` matrix (length `d^2`). +/// Convention: `vec(A rho B) = (B^T ⊗ A) vec(rho)`. +pub fn vec_of(m: &Matrix, d: usize) -> Vec { + assert_eq!(m.len(), d * d); + let mut out = vec![Complex64::new(0.0, 0.0); d * d]; + // Column-major layout: vec(M)[i + d*j] = M[i, j] = m[i*d + j]. + for i in 0..d { + for j in 0..d { + out[i + d * j] = m[i * d + j]; + } + } + out +} + +/// Inverse of [`vec_of`]: reshape a `d^2` vector back to a `d x d` +/// matrix (row-major storage). +pub fn unvec(v: &[Complex64], d: usize) -> Matrix { + assert_eq!(v.len(), d * d); + let mut m = vec![Complex64::new(0.0, 0.0); d * d]; + for i in 0..d { + for j in 0..d { + m[i * d + j] = v[i + d * j]; + } + } + m +} + +/// Matrix-vector product `A * v` for a `n x n` matrix `A` and length-`n` +/// vector `v`. +pub fn matvec(a: &Matrix, v: &[Complex64], n: usize) -> Vec { + assert_eq!(a.len(), n * n); + assert_eq!(v.len(), n); + let mut out = vec![Complex64::new(0.0, 0.0); n]; + for i in 0..n { + let mut s = Complex64::new(0.0, 0.0); + for j in 0..n { + s += a[i * n + j] * v[j]; + } + out[i] = s; + } + out +} + +/// Matrix exponential `exp(-i * H * t)` for a 2x2 traceless Hermitian H. +/// Uses the Bloch form: `exp(-i H t) = cos(r t) I - i sin(r t) H / r` +/// where `r = sqrt(c_x^2 + c_y^2 + c_z^2)` is the Pauli-decomposition norm. +/// Panics if `H` has nonzero trace. +pub fn exp_minus_i_h_t_1q_traceless(h: &Matrix, t: f64) -> Matrix { + assert_eq!(h.len(), 4, "requires a 2x2 matrix"); + // Check Hermitian (tolerant). + let h00 = h[0]; + let h01 = h[1]; + let h10 = h[2]; + let h11 = h[3]; + assert!( + h00.im.abs() < 1e-12 && h11.im.abs() < 1e-12, + "H not Hermitian (diagonal)" + ); + assert!( + (h10 - h01.conj()).norm() < 1e-12, + "H not Hermitian (off-diagonal)" + ); + let tr = (h00 + h11).re; + assert!(tr.abs() < 1e-12, "H must be traceless; got trace = {}", tr); + + // Pauli decomposition: H = c_x X + c_y Y + c_z Z. + let c_x = h01.re; + let c_y = -h01.im; // since H_{01} = c_x - i c_y + let c_z = (h00.re - h11.re) * 0.5; + + let r = (c_x * c_x + c_y * c_y + c_z * c_z).sqrt(); + if r < 1e-15 { + return identity(2); + } + let c = (r * t).cos(); + let s = (r * t).sin() / r; + let minus_i_s = Complex64::new(0.0, -s); + // result = c * I - i s * H + let i2 = identity(2); + add(&scale(&i2, Complex64::new(c, 0.0)), &scale(h, minus_i_s)) +} diff --git a/exp/pecos-lindblad/src/noise_models.rs b/exp/pecos-lindblad/src/noise_models.rs new file mode 100644 index 000000000..33202bade --- /dev/null +++ b/exp/pecos-lindblad/src/noise_models.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. + +//! Device-parameter convenience constructors for noise [`Lindbladian`]s. +//! +//! Real QEC experiments are typically specified in terms of coherence +//! times `(T_1, T_2)`, not raw Lindblad rates. This module converts +//! between them and builds the tensor-product Lindbladians that the +//! paper-fixture tests would otherwise hand-roll. +//! +//! # T1/T2 convention +//! +//! Standard textbook relation: +//! +//! ```text +//! beta_down = 1 / T_1 +//! 1 / T_2 = 1 / (2 T_1) + 1 / T_phi +//! beta_phi = 1 / T_phi = 1/T_2 - 1/(2 T_1) +//! ``` +//! +//! `T_2 >= 2 T_1 / (1 + 2 T_1 / T_phi)`; pure-dephasing-free limit is +//! `T_2 = 2 T_1` with `beta_phi = 0`. + +use num_complex::Complex64; + +use crate::basis::Pauli1; +use crate::lindbladian::Lindbladian; +use crate::matrix::{self, Matrix}; + +/// Convert `(T_1, T_2)` to `(beta_down, beta_phi)`. Panics if `T_2 > 2 T_1` +/// (unphysical -- dephasing would be negative). +pub fn t1_t2_to_rates(t1: f64, t2: f64) -> (f64, f64) { + assert!(t1 > 0.0, "T_1 must be positive"); + assert!(t2 > 0.0, "T_2 must be positive"); + let beta_down = 1.0 / t1; + let inv_tphi = 1.0 / t2 - 1.0 / (2.0 * t1); + assert!( + inv_tphi >= -1e-15, + "T_2 ({}) > 2 T_1 ({}) violates 1/T_phi = 1/T_2 - 1/(2 T_1) >= 0", + t2, + 2.0 * t1, + ); + (beta_down, inv_tphi.max(0.0)) +} + +/// 1-qubit amplitude-damping + pure-dephasing Lindbladian from `(T_1, T_2)`. +/// +/// Collapse operators: `sigma_- with rate 1/T_1`, `Z with rate beta_phi/2` +/// where `beta_phi = 1/T_2 - 1/(2 T_1)`. +pub fn ad_pd_1q(t1: f64, t2: f64) -> Lindbladian { + let (beta_down, beta_phi) = t1_t2_to_rates(t1, t2); + let d = 2; + let hamiltonian = matrix::zeros(d); + let collapse: Vec<(Matrix, f64)> = vec![ + (matrix::sigma_minus(), beta_down), + (matrix::pauli_1q(Pauli1::Z), beta_phi / 2.0), + ]; + Lindbladian::new(d, hamiltonian, collapse) +} + +/// 2-qubit amplitude-damping + pure-dephasing, independently parameterised +/// on left (`l`) and right (`r`) qubits. +pub fn ad_pd_2q(t1_l: f64, t1_r: f64, t2_l: f64, t2_r: f64) -> Lindbladian { + let (bd_l, bp_l) = t1_t2_to_rates(t1_l, t2_l); + let (bd_r, bp_r) = t1_t2_to_rates(t1_r, t2_r); + let d = 4; + let i2 = matrix::identity(2); + let sm = matrix::sigma_minus(); + let z = matrix::pauli_1q(Pauli1::Z); + let sm_l = matrix::kron(&sm, &i2, 2, 2); + let sm_r = matrix::kron(&i2, &sm, 2, 2); + let z_l = matrix::kron(&z, &i2, 2, 2); + let z_r = matrix::kron(&i2, &z, 2, 2); + let collapse: Vec<(Matrix, f64)> = vec![ + (sm_l, bd_l), + (sm_r, bd_r), + (z_l, bp_l / 2.0), + (z_r, bp_r / 2.0), + ]; + Lindbladian::new(d, matrix::zeros(d), collapse) +} + +/// 2-qubit coherent phase noise: +/// `H_delta = (delta_iz/2) IZ + (delta_zi/2) ZI + (delta_zz/2) ZZ`, +/// no collapse operators (use [`crate::synthesize_exact_unitary`]). +pub fn coherent_phase_2q(delta_iz: f64, delta_zi: f64, delta_zz: f64) -> Lindbladian { + let d = 4; + let i2 = matrix::identity(2); + let z = matrix::pauli_1q(Pauli1::Z); + let iz = matrix::kron(&i2, &z, 2, 2); + let zi = matrix::kron(&z, &i2, 2, 2); + let zz = matrix::kron(&z, &z, 2, 2); + let half = Complex64::new(0.5, 0.0); + let h_delta = matrix::add( + &matrix::add( + &matrix::scale(&iz, Complex64::new(delta_iz, 0.0) * half), + &matrix::scale(&zi, Complex64::new(delta_zi, 0.0) * half), + ), + &matrix::scale(&zz, Complex64::new(delta_zz, 0.0) * half), + ); + Lindbladian::new(d, h_delta, Vec::new()) +} + +/// Analytic back-solve: given measured PL rates from a 1Q identity gate +/// with AD+PD noise, recover `(T_1, T_2)`. +/// +/// Inverse of [`ad_pd_1q`] applied to [`crate::synthesize_identity_1q`] +/// using the paper's closed form (line 812): +/// +/// ```text +/// lambda_x = lambda_y = beta_down * tau_g / 4 => T_1 = tau_g / (4 lambda_x) +/// lambda_z = beta_phi * tau_g / 2 => T_phi = tau_g / (2 lambda_z) +/// 1/T_2 = 1/(2 T_1) + 1/T_phi => T_2 = 1 / (1/(2 T_1) + 1/T_phi) +/// ``` +/// +/// Returns `None` if rates are inconsistent (e.g. negative or would imply +/// `T_2 > 2 T_1`). Averages `lambda_x` and `lambda_y` for robustness +/// against small measurement noise. +pub fn recover_t1_t2_from_identity_1q( + model: &crate::PauliLindbladModel, + tau_g: f64, +) -> Option<(f64, f64)> { + use crate::{Pauli1, PauliString}; + if tau_g <= 0.0 { + return None; + } + let lam_x = model.rate(&PauliString::single(Pauli1::X)); + let lam_y = model.rate(&PauliString::single(Pauli1::Y)); + let lam_z = model.rate(&PauliString::single(Pauli1::Z)); + // AD gives equal lambda_x and lambda_y; average for noise robustness. + let lam_avg_xy = 0.5 * (lam_x + lam_y); + if lam_avg_xy <= 0.0 || lam_z < 0.0 { + return None; + } + let t1 = tau_g / (4.0 * lam_avg_xy); + if t1 <= 0.0 { + return None; + } + // lambda_z = 0 is allowed (pure-T1 limit => T_2 = 2 T_1). + let t2 = if lam_z < 1e-30 { + 2.0 * t1 + } else { + let t_phi = tau_g / (2.0 * lam_z); + let inv_t2 = 1.0 / (2.0 * t1) + 1.0 / t_phi; + if inv_t2 <= 0.0 { + return None; + } + 1.0 / inv_t2 + }; + Some((t1, t2)) +} + +/// Recovered 2-qubit coherence-time parameters for the (left, right) +/// qubits of a 2Q gate. +#[derive(Clone, Copy, Debug, PartialEq)] +pub struct RecoveredParams2Q { + pub t1_l: f64, + pub t2_l: f64, + pub t1_r: f64, + pub t2_r: f64, +} + +/// Analytic 2Q recovery: given a measured `CZ_theta + AD+PD` PL model, +/// back-solve `(T_1, T_2)` on each qubit. +/// +/// Uses paper arXiv:2502.03462 eqs. 896-906: +/// +/// ```text +/// lambda_zi = (theta/2) * (beta_phi_l / omega_cz) +/// lambda_iz = (theta/2) * (beta_phi_r / omega_cz) +/// lambda_xi = lambda_yi = (2*theta + sin(2*theta))/16 * beta_down_l / omega_cz +/// lambda_ix = lambda_iy = (2*theta + sin(2*theta))/16 * beta_down_r / omega_cz +/// ``` +/// +/// Averages the degenerate-in-paper pairs (`lambda_xi` ≈ `lambda_yi`) +/// for robustness against noisy measurements. Returns `None` if rates +/// are inconsistent (e.g. negative) or would imply `T_2 > 2 T_1`. +pub fn recover_ad_pd_2q_from_cz_theta( + model: &crate::PauliLindbladModel, + omega_cz: f64, + theta: f64, +) -> Option { + use crate::PauliString; + if omega_cz <= 0.0 || theta <= 0.0 { + return None; + } + let r = |s: &str| model.rate(&PauliString::from_label(s).unwrap()); + let factor_weight1_amp = (2.0 * theta + (2.0 * theta).sin()) / 16.0; + + // Amplitude damping: average the two equal rates (paper's 2-fold degeneracy). + let beta_down_r = 0.5 * (r("IX") + r("IY")) * omega_cz / factor_weight1_amp; + let beta_down_l = 0.5 * (r("XI") + r("YI")) * omega_cz / factor_weight1_amp; + + // Dephasing: single-rate back-solve. + let beta_phi_r = r("IZ") * 2.0 * omega_cz / theta; + let beta_phi_l = r("ZI") * 2.0 * omega_cz / theta; + + if beta_down_l < 0.0 || beta_down_r < 0.0 || beta_phi_l < 0.0 || beta_phi_r < 0.0 { + return None; + } + if beta_down_l < 1e-300 || beta_down_r < 1e-300 { + return None; + } + + let t1_l = 1.0 / beta_down_l; + let t1_r = 1.0 / beta_down_r; + // 1/T_2 = 1/(2 T_1) + 1/T_phi and 1/T_phi = beta_phi. + let inv_t2_l = 1.0 / (2.0 * t1_l) + beta_phi_l; + let inv_t2_r = 1.0 / (2.0 * t1_r) + beta_phi_r; + if inv_t2_l <= 0.0 || inv_t2_r <= 0.0 { + return None; + } + Some(RecoveredParams2Q { + t1_l, + t2_l: 1.0 / inv_t2_l, + t1_r, + t2_r: 1.0 / inv_t2_r, + }) +} + +/// Analytic 2Q recovery: given a measured `CX_theta + AD+PD` PL model, +/// back-solve `(T_1, T_2)` on each qubit. +/// +/// Uses paper arXiv:2502.03462 eqs. 929-956. `beta_down_r` and +/// `beta_phi_r` mix in `lambda_iz` / `lambda_zz`; we exploit the +/// identity +/// +/// ```text +/// lambda_iz - lambda_zz = sin(2*theta)/4 * beta_phi_r / omega +/// ``` +/// +/// to decouple the right-qubit dephasing. Requires `sin(2 theta) != 0` +/// -- at `theta = 0, pi/2, pi` the formula is degenerate and only +/// `beta_down_r + 6 beta_phi_r` is recoverable; we return `None`. +/// `beta_down_l` and `beta_phi_l` come from clean single-unknown rates +/// (`lambda_xi`, `lambda_zi`). +pub fn recover_ad_pd_2q_from_cx_theta( + model: &crate::PauliLindbladModel, + omega_cx: f64, + theta: f64, +) -> Option { + use crate::PauliString; + if omega_cx <= 0.0 || theta <= 0.0 { + return None; + } + let s2 = (2.0 * theta).sin(); + if s2.abs() < 1e-10 { + // Degenerate: can't separate beta_down_r from beta_phi_r. + return None; + } + let r = |s: &str| model.rate(&PauliString::from_label(s).unwrap()); + + // Left qubit: clean single-parameter back-solves. + let factor_weight1_amp_l = (2.0 * theta + s2) / 16.0; + let beta_down_l = 0.5 * (r("XI") + r("YI")) * omega_cx / factor_weight1_amp_l; + let beta_phi_l = r("ZI") * 2.0 * omega_cx / theta; + + // Right qubit: beta_down_r from clean lambda_ix; beta_phi_r via the + // (lambda_iz - lambda_zz) decoupling. + let beta_down_r = r("IX") * 4.0 * omega_cx / theta; + let beta_phi_r = (r("IZ") - r("ZZ")) * 4.0 * omega_cx / s2; + + if beta_down_l < 0.0 || beta_down_r < 0.0 || beta_phi_l < 0.0 || beta_phi_r < 0.0 { + return None; + } + if beta_down_l < 1e-300 || beta_down_r < 1e-300 { + return None; + } + let t1_l = 1.0 / beta_down_l; + let t1_r = 1.0 / beta_down_r; + let inv_t2_l = 1.0 / (2.0 * t1_l) + beta_phi_l; + let inv_t2_r = 1.0 / (2.0 * t1_r) + beta_phi_r; + if inv_t2_l <= 0.0 || inv_t2_r <= 0.0 { + return None; + } + Some(RecoveredParams2Q { + t1_l, + t2_l: 1.0 / inv_t2_l, + t1_r, + t2_r: 1.0 / inv_t2_r, + }) +} + +/// Consistency residual for a `CZ_theta + AD+PD` recovery: for the +/// recovered parameters, compute the L2 residual between observed +/// degenerate-pair rates (`lambda_xi` vs `lambda_yi`, etc.). Large +/// residuals flag model mismatch (noise source beyond AD+PD). +pub fn cz_recovery_residual(model: &crate::PauliLindbladModel) -> f64 { + use crate::PauliString; + let r = |s: &str| model.rate(&PauliString::from_label(s).unwrap()); + let pairs = [ + (r("IX"), r("IY")), + (r("XI"), r("YI")), + (r("ZX"), r("ZY")), + (r("XZ"), r("YZ")), + ]; + pairs + .iter() + .map(|(a, b)| (a - b).powi(2)) + .sum::() + .sqrt() +} + +/// Per-Pauli mean + standard-deviation statistics from a Monte-Carlo +/// uncertainty propagation. +#[derive(Clone, Debug, Default)] +pub struct RateUncertainty { + pub paulis: Vec, + /// `means[i]` = mean of `rates[i]` across MC samples. + pub means: Vec, + /// `stds[i]` = sample standard deviation of `rates[i]`. + pub stds: Vec, + /// Number of samples drawn. + pub n_samples: usize, +} + +/// Propagate parameter uncertainty into the Pauli-Lindblad rates by +/// Monte-Carlo sampling. +/// +/// `synthesize_sample` is called once per MC draw. The user builds the +/// `Gate` from the (possibly jittered) parameters and returns the +/// synthesized [`PauliLindbladModel`]. This routine aggregates across +/// draws: per-Pauli sample mean + sample standard deviation. +/// +/// The first sample determines the support enumeration; later samples +/// are matched by support equality, so stochastic supports (same Pauli +/// but generated in a different order) are fine. +pub fn propagate_uncertainty( + n_samples: usize, + mut synthesize_sample: impl FnMut(usize) -> crate::PauliLindbladModel, +) -> RateUncertainty { + assert!(n_samples >= 2, "need >=2 samples to compute std"); + + // Use the first sample to fix the Pauli set. + let first = synthesize_sample(0); + let n_p = first.supports.len(); + let mut sum = first.rates.clone(); + let mut sum_sq: Vec = first.rates.iter().map(|r| r * r).collect(); + + for k in 1..n_samples { + let model = synthesize_sample(k); + assert_eq!( + model.supports.len(), + n_p, + "MC draw {}: support size {} != expected {}", + k, + model.supports.len(), + n_p, + ); + for (i, p) in first.supports.iter().enumerate() { + let r = model.rate(p); + sum[i] += r; + sum_sq[i] += r * r; + } + } + + let n = n_samples as f64; + let means: Vec = sum.iter().map(|s| s / n).collect(); + let stds: Vec = sum_sq + .iter() + .zip(means.iter()) + .map(|(ss, m)| { + let var = (ss / n - m * m).max(0.0); + var.sqrt() + }) + .collect(); + + RateUncertainty { + paulis: first.supports, + means, + stds, + n_samples, + } +} + +impl RateUncertainty { + /// Look up mean rate for a given Pauli; returns 0 if not in support. + pub fn mean(&self, p: &crate::PauliString) -> f64 { + self.paulis + .iter() + .zip(&self.means) + .find(|(s, _)| *s == p) + .map(|(_, v)| *v) + .unwrap_or(0.0) + } + + /// Look up standard deviation for a given Pauli; returns 0 if not in support. + pub fn std(&self, p: &crate::PauliString) -> f64 { + self.paulis + .iter() + .zip(&self.stds) + .find(|(s, _)| *s == p) + .map(|(_, v)| *v) + .unwrap_or(0.0) + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn t1_t2_round_trip() { + let t1 = 100e-6; + let t2 = 80e-6; // < 2 T_1 so physical + let (bd, bp) = t1_t2_to_rates(t1, t2); + assert!((bd - 1.0 / t1).abs() < 1e-15); + assert!((bp - (1.0 / t2 - 1.0 / (2.0 * t1))).abs() < 1e-15); + } + + #[test] + fn t2_equals_2_t1_gives_zero_dephasing() { + // Dephasing-free limit. + let t1 = 100e-6; + let (bd, bp) = t1_t2_to_rates(t1, 2.0 * t1); + assert!((bd - 1.0 / t1).abs() < 1e-15); + assert!(bp < 1e-15, "bp should be ~0, got {}", bp); + } + + #[test] + #[should_panic(expected = "T_2")] + fn unphysical_t2_panics() { + let _ = t1_t2_to_rates(100e-6, 300e-6); // T_2 > 2 T_1 + } +} diff --git a/exp/pecos-lindblad/src/pauli_lindblad.rs b/exp/pecos-lindblad/src/pauli_lindblad.rs new file mode 100644 index 000000000..f103a1c37 --- /dev/null +++ b/exp/pecos-lindblad/src/pauli_lindblad.rs @@ -0,0 +1,447 @@ +// 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. + +//! Sparse Pauli-Lindblad noise model (arXiv:2201.09866 generator form). + +use std::collections::BTreeMap; + +use pecos_quantum::{ChannelError, DiagonalPtm, basis_bitmask, basis_label}; +use rand::{Rng, RngExt}; + +use crate::basis::{Pauli1, PauliString}; + +/// Sparse Pauli-Lindblad generator: +/// `N(rho) = exp( sum_k lambda_k * (P_k rho P_k^dag - rho) )`. +/// `rates[i]` is the integrated rate `lambda_k` (dimensionless) for +/// `supports[i]`. All rates are non-negative for forward simulation. +#[derive(Clone, Debug, Default)] +#[cfg_attr(feature = "serde", derive(serde::Serialize, serde::Deserialize))] +pub struct PauliLindbladModel { + pub supports: Vec, + pub rates: Vec, +} + +impl PauliLindbladModel { + pub fn new(supports: Vec, rates: Vec) -> Self { + assert_eq!( + supports.len(), + rates.len(), + "supports/rates length mismatch" + ); + for &r in &rates { + assert!(r >= 0.0, "negative PL rate: {}", r); + } + Self { supports, rates } + } + + /// Look up the rate for a given Pauli support. Returns 0 if not present. + pub fn rate(&self, p: &PauliString) -> f64 { + self.supports + .iter() + .zip(&self.rates) + .find(|(s, _)| *s == p) + .map(|(_, r)| *r) + .unwrap_or(0.0) + } + + /// Sum of all rates. To leading order this is the total probability of + /// *any* Pauli error firing during the gate. + pub fn total_rate(&self) -> f64 { + self.rates.iter().sum() + } + + /// Number of qubits spanned by this model. + #[must_use] + pub fn num_qubits(&self) -> usize { + self.supports + .iter() + .map(PauliString::num_qubits) + .max() + .unwrap_or(0) + } + + /// Converts the Pauli-Lindblad model to diagonal PTM fidelities. + /// + /// For `N(rho) = exp(sum_k lambda_k (P_k rho P_k - rho))`, a Pauli basis + /// element `B` has diagonal PTM entry + /// `exp(-2 * sum_{k: {P_k, B}=0} lambda_k)`. + /// + /// # Errors + /// + /// Returns an error when supports have inconsistent qubit counts or the + /// PECOS Pauli-basis dimension cannot be represented. + pub fn to_diagonal_ptm(&self) -> Result { + let n = self.num_qubits(); + for support in &self.supports { + if support.num_qubits() != n { + return Err(ChannelError::UnsupportedChannelExpr { + reason: format!( + "PauliLindbladModel support {} has {} qubits in a {}-qubit model", + support, + support.num_qubits(), + n + ), + }); + } + } + + let basis_len = pecos_quantum::pauli_basis_len(n)?; + let mut fidelities = BTreeMap::new(); + for basis_idx in 0..basis_len { + let label = basis_label(n, basis_idx)?; + let basis_pauli = PauliString::from_label(&label).ok_or_else(|| { + ChannelError::UnsupportedChannelExpr { + reason: format!("internal basis label {label} was not a Lindblad Pauli string"), + } + })?; + let anticommuting_rate: f64 = self + .supports + .iter() + .zip(&self.rates) + .filter(|(support, _)| support.symplectic_product(&basis_pauli) == 1) + .map(|(_, rate)| *rate) + .sum(); + fidelities.insert( + basis_bitmask(n, basis_idx)?, + (-2.0 * anticommuting_rate).exp(), + ); + } + DiagonalPtm::try_new(n, fidelities) + } + + /// Sum of rates restricted to a given Pauli weight (number of + /// non-identity factors). + pub fn rate_at_weight(&self, weight: usize) -> f64 { + self.supports + .iter() + .zip(&self.rates) + .filter(|(s, _)| s.weight() == weight) + .map(|(_, r)| *r) + .sum() + } + + /// Largest single rate in the model. + pub fn max_rate(&self) -> f64 { + self.rates.iter().copied().fold(0.0, f64::max) + } + + /// Adapter: export 1-qubit rates as `[lambda_X, lambda_Y, lambda_Z]`. + /// Panics if the model is not 1-qubit. + /// + /// Intended consumer: `pecos-qec::PerGateTypeNoise::with_1q_rates`. + pub fn to_noise_array_1q(&self) -> [f64; 3] { + assert!( + self.supports.iter().all(|s| s.num_qubits() == 1), + "to_noise_array_1q requires a 1-qubit model" + ); + [ + self.rate(&PauliString::single(Pauli1::X)), + self.rate(&PauliString::single(Pauli1::Y)), + self.rate(&PauliString::single(Pauli1::Z)), + ] + } + + /// Adapter: export 2-qubit rates in `PAULI_2Q_ORDER` ordering + /// (IX, IY, IZ, XI, XX, XY, XZ, YI, YX, YY, YZ, ZI, ZX, ZY, ZZ). + /// Panics if the model is not 2-qubit. + /// + /// Intended consumer: `pecos-qec::PerGateTypeNoise::with_2q_rates`. + pub fn to_noise_array_2q(&self) -> [f64; 15] { + assert!( + self.supports.iter().all(|s| s.num_qubits() == 2), + "to_noise_array_2q requires a 2-qubit model" + ); + const ORDER: [&str; 15] = [ + "IX", "IY", "IZ", "XI", "XX", "XY", "XZ", "YI", "YX", "YY", "YZ", "ZI", "ZX", "ZY", + "ZZ", + ]; + let mut out = [0.0; 15]; + for (i, label) in ORDER.iter().enumerate() { + out[i] = self.rate(&PauliString::from_label(label).unwrap()); + } + out + } + + /// Per-Pauli residual `self - other`. Returns a vector of + /// `(pauli, self_rate, other_rate, residual)` for every Pauli in the + /// union of the two models' supports. + pub fn diff(&self, other: &Self) -> Vec<(PauliString, f64, f64, f64)> { + use std::collections::HashSet; + let mut all: HashSet = HashSet::new(); + for p in self.supports.iter().chain(other.supports.iter()) { + all.insert(p.clone()); + } + let mut out: Vec<_> = all + .into_iter() + .map(|p| { + let a = self.rate(&p); + let b = other.rate(&p); + (p, a, b, a - b) + }) + .collect(); + out.sort_by(|(_, _, _, x), (_, _, _, y)| { + y.abs() + .partial_cmp(&x.abs()) + .unwrap_or(std::cmp::Ordering::Equal) + }); + out + } + + /// `L2` norm of the rate-residual vector between `self` and `other`. + pub fn residual_l2(&self, other: &Self) -> f64 { + self.diff(other) + .iter() + .map(|(_, _, _, r)| r * r) + .sum::() + .sqrt() + } + + /// Pauli with the largest absolute residual against `other`. Returns + /// `None` if both models are empty. + pub fn max_residual(&self, other: &Self) -> Option<(PauliString, f64)> { + self.diff(other) + .into_iter() + .next() + .map(|(p, _, _, r)| (p, r)) + } + + /// Leading-order composition of two independent noise sources: rates + /// add per Pauli. Exact for small rates where `(1 - (1-p_A)(1-p_B)) ≈ + /// p_A + p_B`. For larger rates, prefer [`synthesize_superop`] on the + /// combined physical Lindbladian directly (all-orders). + /// + /// Use case: combine a predicted physical-model PL with an + /// experimentally-observed residual-noise PL to get an effective + /// model for circuit-level noise. + pub fn compose_independent(&self, other: &Self) -> Self { + use std::collections::HashMap; + let mut combined: HashMap = HashMap::new(); + for (p, r) in self.supports.iter().zip(&self.rates) { + combined.insert(p.clone(), *r); + } + for (p, r) in other.supports.iter().zip(&other.rates) { + *combined.entry(p.clone()).or_insert(0.0) += *r; + } + let mut entries: Vec<_> = combined.into_iter().collect(); + entries.sort_by(|(a, _), (b, _)| { + a.0.iter() + .map(|p| *p as u8) + .cmp(b.0.iter().map(|p| *p as u8)) + }); + let (supports, rates): (Vec<_>, Vec<_>) = entries.into_iter().unzip(); + Self::new(supports, rates) + } + + /// Aggregate absolute residual by Pauli weight. Returns `weight -> sum + /// of |residual|`. Useful for diagnosing which weight class of physics + /// is missing from the model (e.g. weight-2 residual large => + /// correlated two-qubit noise missing). + pub fn residual_by_weight(&self, other: &Self) -> Vec<(usize, f64)> { + use std::collections::BTreeMap; + let mut agg: BTreeMap = BTreeMap::new(); + for (p, _, _, r) in self.diff(other) { + *agg.entry(p.weight()).or_insert(0.0) += r.abs(); + } + agg.into_iter().collect() + } + + /// Return the top `n` Pauli terms sorted by rate (descending). + /// Ties broken by lexicographic order on the Pauli string. + pub fn top_contributors(&self, n: usize) -> Vec<(PauliString, f64)> { + let mut pairs: Vec<_> = self + .supports + .iter() + .cloned() + .zip(self.rates.iter().copied()) + .collect(); + pairs.sort_by(|a, b| { + b.1.partial_cmp(&a.1) + .unwrap_or(std::cmp::Ordering::Equal) + .then_with(|| { + a.0.0 + .iter() + .map(|p| *p as u8) + .cmp(b.0.0.iter().map(|p| *p as u8)) + }) + }); + pairs.truncate(n); + pairs + } + + /// Human-readable noise-budget table: total rate, per-weight-class + /// breakdown, and top contributors. Useful for answering "where + /// is my logical error budget going?" + /// + /// Format is stable for eyeballing; not an interchange format + /// (use `serde` for that). + pub fn explain(&self) -> String { + let total = self.total_rate(); + let n_terms = self.supports.len(); + + let mut by_weight: std::collections::BTreeMap = + std::collections::BTreeMap::new(); + for (p, r) in self.supports.iter().zip(&self.rates) { + *by_weight.entry(p.weight()).or_insert(0.0) += *r; + } + + let mut out = String::new(); + out.push_str(&format!( + "Pauli-Lindblad noise budget ({} terms, total rate = {:.3e})\n", + n_terms, total, + )); + out.push_str(&"=".repeat(60)); + out.push('\n'); + out.push_str("By weight:\n"); + for (w, r) in &by_weight { + let pct = if total > 0.0 { 100.0 * r / total } else { 0.0 }; + out.push_str(&format!(" weight-{}: {:>11.3e} {:5.1}%\n", w, r, pct)); + } + out.push('\n'); + + let top_n = 10; + out.push_str(&format!("Top {} contributors:\n", top_n.min(n_terms))); + for (p, r) in self.top_contributors(top_n) { + let pct = if total > 0.0 { 100.0 * r / total } else { 0.0 }; + out.push_str(&format!( + " {:<12} {:>11.3e} {:5.1}%\n", + p.to_string(), + r, + pct + )); + } + out + } + + /// Heuristic diagnostic: given a predicted model (`self`) and a + /// measured model (`other`), suggest physical sources likely missing + /// from the prediction. Returns human-readable strings ordered by + /// residual magnitude. Thresholds are intentionally coarse; use as a + /// starting point, not a final verdict. + pub fn diagnose_gap(&self, other: &Self, tol: f64) -> Vec { + let mut msgs = Vec::new(); + let by_weight = self.residual_by_weight(other); + + for (weight, total_abs) in &by_weight { + if *total_abs < tol { + continue; + } + match weight { + 1 => msgs.push(format!( + "weight-1 residual {:.3e}: suggests missing incoherent single-qubit noise \ + (T_1, T_phi mischaracterized, or extra dephasing/relaxation channels)", + total_abs + )), + 2 => msgs.push(format!( + "weight-2 residual {:.3e}: suggests correlated 2-qubit noise not in model \ + (coherent ZZ crosstalk, leakage-induced correlations, or gate miscalibration)", + total_abs + )), + w if *w >= 3 => msgs.push(format!( + "weight-{} residual {:.3e}: high-weight residual; suggests multi-qubit \ + crosstalk or higher-order Magnus corrections needed", + w, total_abs + )), + _ => {} + } + } + // Highlight the single worst Pauli as a concrete pointer. + if let Some((p, r)) = self.max_residual(other) + && r.abs() >= tol + { + msgs.push(format!( + "largest per-Pauli residual: |lambda_{{{}}}^pred - lambda_{{{}}}^meas| = {:.3e}", + p, p, r + )); + } + msgs + } + + /// Sample an error realization over integrated duration `t_scale`: + /// each Pauli term independently fires with probability + /// `p_k = (1 - exp(-2 * lambda_k * t_scale)) / 2`. Returns the + /// product Pauli string (may be identity). + pub fn sample(&self, t_scale: f64, rng: &mut impl Rng) -> PauliString { + assert!(!self.supports.is_empty(), "cannot sample empty model"); + let n = self.supports[0].num_qubits(); + let mut acc = PauliString(vec![Pauli1::I; n]); + for (support, &lambda) in self.supports.iter().zip(&self.rates) { + assert_eq!(support.num_qubits(), n, "ragged supports"); + let p_flip = 0.5 * (1.0 - (-2.0 * lambda * t_scale).exp()); + if rng.random_range(0.0..1.0) < p_flip { + acc = acc.multiply(support); + } + } + acc + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn summary_helpers() { + let supports = vec![ + PauliString::from_label("IX").unwrap(), + PauliString::from_label("IZ").unwrap(), + PauliString::from_label("XX").unwrap(), + ]; + let rates = vec![0.001, 0.003, 0.002]; + let model = PauliLindbladModel::new(supports, rates); + assert!((model.total_rate() - 0.006).abs() < 1e-12); + assert!((model.rate_at_weight(1) - 0.004).abs() < 1e-12); // IX + IZ + assert!((model.rate_at_weight(2) - 0.002).abs() < 1e-12); // XX + assert!((model.max_rate() - 0.003).abs() < 1e-12); + } + + #[test] + fn sample_zero_rates_is_identity() { + use rand::SeedableRng; + use rand::rngs::StdRng; + let supports = vec![ + PauliString::single(Pauli1::X), + PauliString::single(Pauli1::Y), + PauliString::single(Pauli1::Z), + ]; + let model = PauliLindbladModel::new(supports, vec![0.0; 3]); + let mut rng = StdRng::seed_from_u64(42); + for _ in 0..100 { + let s = model.sample(1.0, &mut rng); + assert_eq!(s, PauliString::single(Pauli1::I)); + } + } + + #[test] + fn diagonal_ptm_matches_pauli_lindblad_rates() { + let model = PauliLindbladModel::new( + vec![ + PauliString::single(Pauli1::X), + PauliString::single(Pauli1::Z), + ], + vec![0.2, 0.3], + ); + + let diagonal = model.to_diagonal_ptm().unwrap(); + let label = |s: &str| { + let idx = ["I", "X", "Y", "Z"] + .iter() + .position(|label| *label == s) + .unwrap(); + pecos_quantum::basis_bitmask(1, idx).unwrap() + }; + + assert!((diagonal.fidelity(&label("I")) - 1.0).abs() < 1e-12); + assert!((diagonal.fidelity(&label("X")) - (-0.6_f64).exp()).abs() < 1e-12); + assert!((diagonal.fidelity(&label("Y")) - (-1.0_f64).exp()).abs() < 1e-12); + assert!((diagonal.fidelity(&label("Z")) - (-0.4_f64).exp()).abs() < 1e-12); + } +} diff --git a/exp/pecos-lindblad/src/synthesis.rs b/exp/pecos-lindblad/src/synthesis.rs new file mode 100644 index 000000000..e0f0cdb03 --- /dev/null +++ b/exp/pecos-lindblad/src/synthesis.rs @@ -0,0 +1,391 @@ +// 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. + +//! Phases 1-3: numerical Pauli-Lindblad synthesis for arbitrary-qubit +//! gates via interaction-frame transform + Simpson's rule + Walsh-Hadamard +//! inversion. +//! +//! Entry points: +//! - [`synthesize_identity_1q`]: fast path for 1Q `H_g = 0` (Phase 1; +//! exact + non-perturbative under AD + PD). +//! - [`synthesize_numerical_1q`]: 1Q, general `H_g`, Simpson integrand. +//! - [`synthesize_numerical`]: n-qubit, general `H_g`, Simpson integrand, +//! Walsh-Hadamard rate recovery. +//! +//! See `design/lindblad_magnus_algorithm.md` for the math spec and paper +//! arXiv:2502.03462 for closed-form fixtures. + +use num_complex::Complex64; + +use crate::basis::{Pauli1, PauliString}; +use crate::gate::Gate; +use crate::lindbladian::Lindbladian; +use crate::matrix::{self, Matrix}; +use crate::pauli_lindblad::PauliLindbladModel; + +const PHASE1_PAULIS: [Pauli1; 3] = [Pauli1::X, Pauli1::Y, Pauli1::Z]; + +/// Default number of Simpson intervals for time integration. Composite +/// Simpson's 1/3 rule, order-4 accurate. 1024 gives ~1e-12 for smooth +/// integrands on a bounded interval (sinusoidal at gate frequency up to a +/// few cycles). +pub const DEFAULT_N_STEPS: usize = 1024; + +/// Synthesize a 1-qubit Pauli-Lindblad model from an identity gate. Fast +/// path: identity gate (`H_g = 0`) => interaction-frame Lindbladian is +/// constant and `Omega_1 = L * tau_g`, so no time integration needed. +pub fn synthesize_identity_1q(gate: &Gate) -> PauliLindbladModel { + assert_eq!( + gate.num_qubits, 1, + "synthesize_identity_1q requires 1 qubit" + ); + assert!( + is_zero_matrix(&gate.ideal.hamiltonian), + "synthesize_identity_1q requires H_g = 0", + ); + let tau = gate.tau_g; + let paulis: Vec = PHASE1_PAULIS + .iter() + .map(|&p| PauliString::single(p)) + .collect(); + let alphas: Vec = PHASE1_PAULIS + .iter() + .map(|&p| constant_alpha(&gate.noise, p) * tau) + .collect(); + model_from_alphas_walsh(paulis, alphas, 1) +} + +/// Synthesize a 1-qubit Pauli-Lindblad model from an arbitrary 1-qubit +/// gate via Simpson's rule on `Omega_1 = int_0^{tau_g} L_I(t) dt`. Works +/// for identity (reduces to the Phase 1 result) and for gates like +/// `X_theta`, `Y_theta`, `Z_theta`. +pub fn synthesize_numerical_1q(gate: &Gate, n_steps: usize) -> PauliLindbladModel { + assert_eq!( + gate.num_qubits, 1, + "synthesize_numerical_1q requires 1 qubit" + ); + synthesize_numerical(gate, n_steps) +} + +/// Default number of time slices for `synthesize_superop`. Midpoint-rule +/// propagator per slice is second-order accurate; `N=128` gives ~`1e-10` +/// precision for single-oscillation gates. +pub const DEFAULT_N_SLICES: usize = 128; + +/// **General** synthesis for any gate with any combination of coherent +/// and dissipative noise via time-slicing of the interaction-frame +/// Lindblad superoperator. +/// +/// For each midpoint `t_k`, builds `L_I(t_k) = U_g^dag(t_k) L_noise +/// U_g(t_k)`, exponentiates to get a per-slice propagator +/// `exp(L_I(t_k) * dt)` and multiplies them left-to-right to form +/// `U_total`. Applies to each `vec(P_b)`, extracts Pauli fidelity, inverts +/// via Walsh-Hadamard. +/// +/// This is the only path that handles **gates with `H_g != 0` AND +/// simultaneous coherent + dissipative noise** -- the general case that +/// `synthesize_numerical` (dissipative leading-order only) and +/// `synthesize_exact_unitary` (coherent, asserts no c_ops) don't cover. +/// +/// Cost: `n_slices` per-slice superoperator builds + exps at size +/// `d^2 x d^2`. For 2Q gates `d^2=16`, comfortably sub-second at +/// `n_slices=128`. +pub fn synthesize_superop(gate: &Gate, n_slices: usize) -> PauliLindbladModel { + assert!(n_slices >= 1, "n_slices must be >= 1"); + let n = gate.num_qubits; + let d = 1usize << n; + let d2 = d * d; + let tau = gate.tau_g; + let dt = tau / n_slices as f64; + + let h_g = &gate.ideal.hamiltonian; + let h_delta = &gate.noise.hamiltonian; + + // U_total = prod_k exp(L_I(t_k) * dt), built left-to-right (newest leftmost). + let mut u_total = matrix::identity(d2); + for k in 0..n_slices { + let t_mid = (k as f64 + 0.5) * dt; + let u_g = matrix::exp_minus_i_h_t(h_g, d, t_mid); + let u_g_dag = matrix::dag(&u_g, d); + + // Transform noise operators to the interaction frame at t_mid. + let h_i = matrix::matmul(&matrix::matmul(&u_g_dag, h_delta, d), &u_g, d); + let collapse_i: Vec<(Matrix, f64)> = gate + .noise + .collapse + .iter() + .map(|(c, g)| (matrix::matmul(&matrix::matmul(&u_g_dag, c, d), &u_g, d), *g)) + .collect(); + + // L_I(t_mid) superop. + let lind_i = Lindbladian::new(d, h_i, collapse_i); + let l_super = lind_i.superoperator(); + + // Per-slice propagator and accumulate. + let u_slice = matrix::expm(&matrix::scale(&l_super, Complex64::new(dt, 0.0)), d2); + u_total = matrix::matmul(&u_slice, &u_total, d2); + } + + // Apply to each Pauli, extract fidelities, invert. + let paulis = PauliString::enumerate_nonidentity(n); + let alphas: Vec = paulis + .iter() + .map(|p| { + let p_mat = matrix::pauli_string_mat(p); + let vec_p = matrix::vec_of(&p_mat, d); + let vec_applied = matrix::matvec(&u_total, &vec_p, d2); + let applied = matrix::unvec(&vec_applied, d); + let inner = matrix::trace(&matrix::matmul(&p_mat, &applied, d), d); + let f_b = inner.re / d as f64; + assert!( + f_b > 0.1, + "Pauli fidelity {} too low for {:?}; noise outside weak regime", + f_b, + p, + ); + -f_b.ln() + }) + .collect(); + model_from_alphas_walsh(paulis, alphas, n) +} + +/// Synthesize a Pauli-Lindblad model from **mixed coherent + dissipative +/// noise** on a time-independent ideal Hamiltonian (currently: identity +/// gate, `H_g = 0`) via the full Lindblad superoperator path. +/// +/// Builds `L_super` (`d^2 x d^2`), exponentiates to get the channel +/// `exp(L_super * tau_g)`, applies to each `vec(P_b)`, extracts Pauli +/// fidelity `f_b = (1/d) tr(P_b * Lambda(P_b))`, then inverts via +/// Walsh-Hadamard. Unifies the coherent and dissipative paths for the +/// identity case: matches [`synthesize_identity_1q`] for pure AD+PD, +/// matches [`synthesize_exact_unitary`] for pure coherent noise on +/// identity, and handles **both at once** (the case the other paths +/// reject). +/// +/// Requires `gate.ideal.hamiltonian` to be (numerically) zero -- for +/// non-trivial `H_g` the interaction-frame Lindbladian is time-dependent +/// and requires either Magnus order >= 1 time-ordering (existing +/// `synthesize_numerical` path for linear-order dissipative) or +/// time-slicing (future work). +pub fn synthesize_superop_identity(gate: &Gate) -> PauliLindbladModel { + let n = gate.num_qubits; + let d = 1usize << n; + assert!( + is_zero_matrix(&gate.ideal.hamiltonian), + "synthesize_superop_identity requires H_g = 0 (time-independent L_I)" + ); + let tau = gate.tau_g; + let l_super = gate.noise.superoperator(); + // channel = exp(L_super * tau_g) + let channel = matrix::expm(&matrix::scale(&l_super, Complex64::new(tau, 0.0)), d * d); + + let paulis = PauliString::enumerate_nonidentity(n); + let alphas: Vec = paulis + .iter() + .map(|p| { + let p_mat = matrix::pauli_string_mat(p); + let vec_p = matrix::vec_of(&p_mat, d); + let vec_applied = matrix::matvec(&channel, &vec_p, d * d); + let applied = matrix::unvec(&vec_applied, d); + let inner = matrix::trace(&matrix::matmul(&p_mat, &applied, d), d); + let f_b = inner.re / d as f64; + assert!( + f_b > 0.1, + "Pauli fidelity {} too low for {:?}; noise outside weak-coupling regime", + f_b, + p, + ); + -f_b.ln() + }) + .collect(); + model_from_alphas_walsh(paulis, alphas, n) +} + +/// Synthesize a Pauli-Lindblad model for a gate with **purely coherent +/// noise** (no collapse operators) via the exact error-unitary path. +/// +/// For coherent noise the Pauli rates are quadratic in the perturbation +/// strength (see `design/lindblad_magnus_algorithm.md` section 4.5). The +/// linear-order [`synthesize_numerical`] path gives `alpha_b = 0` for +/// coherent noise because `Tr(P_b L(P_b)) = 0` when `L` is a single +/// commutator. This function computes the exact error unitary +/// `U_err = U_ideal^dag * U_full` and extracts Pauli fidelities directly. +/// +/// Requires `gate.noise.collapse` to be empty. Use +/// [`synthesize_numerical`] for dissipative noise (AD, PD). +pub fn synthesize_exact_unitary(gate: &Gate) -> PauliLindbladModel { + assert!( + gate.noise.collapse.is_empty(), + "synthesize_exact_unitary requires purely coherent noise (no c_ops)" + ); + let n = gate.num_qubits; + let d = 1usize << n; + let tau = gate.tau_g; + + let h_g = &gate.ideal.hamiltonian; + let h_delta = &gate.noise.hamiltonian; + let h_full = matrix::add(h_g, h_delta); + + let u_full = matrix::expm(&matrix::scale(&h_full, Complex64::new(0.0, -tau)), d); + let u_ideal = matrix::expm(&matrix::scale(h_g, Complex64::new(0.0, -tau)), d); + let u_ideal_dag = matrix::dag(&u_ideal, d); + let u_err = matrix::matmul(&u_ideal_dag, &u_full, d); + let u_err_dag = matrix::dag(&u_err, d); + + let paulis = PauliString::enumerate_nonidentity(n); + let alphas: Vec = paulis + .iter() + .map(|p| { + let p_mat = matrix::pauli_string_mat(p); + let up = matrix::matmul(&u_err, &p_mat, d); + let upudag = matrix::matmul(&up, &u_err_dag, d); + let inner = matrix::trace(&matrix::matmul(&p_mat, &upudag, d), d); + let f_b = inner.re / d as f64; + // For weak noise f_b ~ 1. Use alpha_b = -ln(f_b); equal to + // (1 - f_b) at leading order. Panic if f_b drifts out of the + // weak-noise regime (< 0.1 means you are outside the Magnus + // convergence radius and the PL model is not a good fit). + assert!( + f_b > 0.1, + "Pauli fidelity {} for {:?} below weak-noise threshold; noise too strong for PL model", + f_b, + p, + ); + -f_b.ln() + }) + .collect(); + + model_from_alphas_walsh(paulis, alphas, n) +} + +/// Synthesize a Pauli-Lindblad model from an arbitrary gate. Enumerates all +/// non-identity Paulis on `gate.num_qubits`, integrates `alpha_b * tau_g` +/// for each via Simpson's rule on the interaction-frame Lindbladian, and +/// inverts via Walsh-Hadamard. +pub fn synthesize_numerical(gate: &Gate, n_steps: usize) -> PauliLindbladModel { + assert!( + n_steps >= 2 && n_steps.is_multiple_of(2), + "n_steps must be even and >= 2, got {}", + n_steps + ); + let n = gate.num_qubits; + let paulis = PauliString::enumerate_nonidentity(n); + if is_zero_matrix(&gate.ideal.hamiltonian) { + let tau = gate.tau_g; + let alphas: Vec = paulis + .iter() + .map(|p| constant_alpha_pauli_string(&gate.noise, p) * tau) + .collect(); + return model_from_alphas_walsh(paulis, alphas, n); + } + + let alphas: Vec = paulis + .iter() + .map(|p| integrated_alpha(gate, p, n_steps)) + .collect(); + model_from_alphas_walsh(paulis, alphas, n) +} + +/// `alpha_b = -Tr(P_b L(P_b)) / d` for time-independent L. Units: 1/time. +fn constant_alpha(noise: &Lindbladian, p: Pauli1) -> f64 { + let d = noise.d; + let p_mat = matrix::pauli_1q(p); + let l_p = noise.apply(&p_mat); + let inner = matrix::trace(&matrix::matmul(&p_mat, &l_p, d), d); + -inner.re / d as f64 +} + +/// `alpha_b = -Tr(P_b L(P_b)) / d` for a time-independent Lindbladian and +/// arbitrary-qubit Pauli string. Units: 1/time. +fn constant_alpha_pauli_string(noise: &Lindbladian, p: &PauliString) -> f64 { + let d = noise.d; + let p_mat = matrix::pauli_string_mat(p); + let l_p = noise.apply(&p_mat); + let inner = matrix::trace(&matrix::matmul(&p_mat, &l_p, d), d); + -inner.re / d as f64 +} + +/// Integrated `alpha_b * tau_g = -Tr(P_b * Omega_1(P_b)) / d` via Simpson's +/// rule on `[0, tau_g]`. Works for any `n_qubits`. +fn integrated_alpha(gate: &Gate, p: &PauliString, n_steps: usize) -> f64 { + let n = gate.num_qubits; + assert_eq!(p.num_qubits(), n); + let d = 1usize << n; + let p_mat = matrix::pauli_string_mat(p); + let h_g = &gate.ideal.hamiltonian; + let tau = gate.tau_g; + let h_step = tau / n_steps as f64; + + // integrand(t) = -Tr(P_b * L_I(t)(P_b)).re / d + // = -Tr(P_b * U_g^†(t) L(U_g(t) P_b U_g^†(t)) U_g(t)) / d + let integrand = |t: f64| -> f64 { + let u = matrix::exp_minus_i_h_t(h_g, d, t); + let u_dag = matrix::dag(&u, d); + let rotated = matrix::matmul(&matrix::matmul(&u, &p_mat, d), &u_dag, d); + let l_rotated = gate.noise.apply(&rotated); + let l_i_pb = matrix::matmul(&matrix::matmul(&u_dag, &l_rotated, d), &u, d); + let inner = matrix::trace(&matrix::matmul(&p_mat, &l_i_pb, d), d); + -inner.re / d as f64 + }; + + // Composite Simpson's 1/3 rule. Weights: 1, 4, 2, 4, 2, ..., 4, 1. + let mut s = integrand(0.0) + integrand(tau); + for k in 1..n_steps { + let t = k as f64 * h_step; + let w = if k % 2 == 1 { 4.0 } else { 2.0 }; + s += w * integrand(t); + } + s * h_step / 3.0 +} + +/// Walsh-Hadamard inversion: +/// `lambda_k = -(1/4^n) * sum_{b non-identity} (-1)^{_sp} alpha_b` +/// (see `design/lindblad_magnus_algorithm.md` step 4). alpha_I = 0 is +/// implicit. +fn model_from_alphas_walsh( + paulis: Vec, + alphas: Vec, + n_qubits: usize, +) -> PauliLindbladModel { + assert_eq!(paulis.len(), alphas.len()); + let norm = 1.0 / (1usize << (2 * n_qubits)) as f64; + let rates: Vec = paulis + .iter() + .map(|k| { + let mut s = 0.0; + for (b, &alpha_b) in paulis.iter().zip(alphas.iter()) { + let sign = if k.symplectic_product(b) == 0 { + 1.0 + } else { + -1.0 + }; + s += sign * alpha_b; + } + clip_negative(-norm * s) + }) + .collect(); + PauliLindbladModel::new(paulis, rates) +} + +fn is_zero_matrix(m: &Matrix) -> bool { + m.iter() + .all(|c: &Complex64| c.re.abs() < 1e-14 && c.im.abs() < 1e-14) +} + +/// Phase 1 positivity policy: clip tiny negatives to zero; panic on large +/// negatives so bugs surface. Revisit in Phase 3 with per-user policy. +fn clip_negative(lambda: f64) -> f64 { + if lambda < -1e-8 { + panic!("PauliLindbladModel rate unexpectedly negative: {}", lambda); + } + lambda.max(0.0) +} diff --git a/exp/pecos-lindblad/src/time_dep.rs b/exp/pecos-lindblad/src/time_dep.rs new file mode 100644 index 000000000..aff773d04 --- /dev/null +++ b/exp/pecos-lindblad/src/time_dep.rs @@ -0,0 +1,216 @@ +// 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. + +//! Time-dependent (time-convolutionless, TCL) Lindbladian support for +//! non-Markovian dynamics. +//! +//! # Scope +//! +//! This module supports **time-local** non-Markovian master equations of +//! the form +//! +//! ```text +//! drho/dt = -i [H_delta(t), rho] + sum_j gamma_j(t) * D[c_j] rho +//! ``` +//! +//! where rates `gamma_j(t)` and coherent noise `H_delta(t)` can vary +//! arbitrarily with time. This covers: +//! +//! - **1/f dephasing**: `gamma_phi(t) = gamma_0 * A / (A + t/t_c)` (leads to +//! non-exponential T_2 decay). +//! - **Gaussian coherence decay**: `gamma_phi(t) ∝ t` gives `exp(-(t/T_2)^2)`. +//! - **Pulse-shape-dependent dephasing**: `gamma_phi(t) ∝ |pulse(t)|^2`. +//! - **Coloured coherent noise**: `H_delta(t) = (delta_0 cos(omega_d t) / 2) Z`. +//! +//! # Out of scope +//! +//! Time-nonlocal (true memory-kernel) master equations like +//! Nakajima-Zwanzig `drho/dt = int_0^t K(t-s) rho(s) ds` require +//! convolution integrals over the history of rho and are not supported. +//! This is a genuine structural limit of the TCL framework. + +use std::sync::Arc; + +use num_complex::Complex64; + +use crate::lindbladian::Lindbladian; +use crate::matrix::Matrix; + +/// Closure type for time-dependent scalar rates. +pub type RateFn = Arc f64 + Send + Sync>; + +/// Closure type for time-dependent Hermitian operators (d x d matrix). +pub type HermitianFn = Arc Matrix + Send + Sync>; + +/// Time-convolutionless (TCL) non-Markovian Lindbladian. +/// +/// Stores a Hilbert-space dimension, a time-dependent coherent noise +/// Hamiltonian (which may be constant zero), and a list of collapse +/// operators with time-dependent rates. Evaluation at time `t` returns a +/// standard [`Lindbladian`] snapshot. +#[derive(Clone)] +pub struct TimeDepLindbladian { + pub d: usize, + pub hamiltonian_fn: HermitianFn, + pub collapse_fns: Vec<(Matrix, RateFn)>, +} + +impl TimeDepLindbladian { + /// Construct with a constant Hamiltonian and per-operator time-dep rates. + pub fn with_static_hamiltonian( + d: usize, + hamiltonian: Matrix, + collapse_fns: Vec<(Matrix, RateFn)>, + ) -> Self { + assert_eq!(hamiltonian.len(), d * d, "hamiltonian wrong shape"); + let h_clone = hamiltonian.clone(); + let hamiltonian_fn: HermitianFn = Arc::new(move |_t| h_clone.clone()); + Self { + d, + hamiltonian_fn, + collapse_fns, + } + } + + /// Construct with fully time-dependent Hamiltonian and rates. + pub fn new(d: usize, hamiltonian_fn: HermitianFn, collapse_fns: Vec<(Matrix, RateFn)>) -> Self { + Self { + d, + hamiltonian_fn, + collapse_fns, + } + } + + /// Evaluate at time `t`, producing a static [`Lindbladian`] snapshot. + pub fn at(&self, t: f64) -> Lindbladian { + let h = (self.hamiltonian_fn)(t); + let collapse: Vec<(Matrix, f64)> = self + .collapse_fns + .iter() + .map(|(op, rate_fn)| (op.clone(), rate_fn(t))) + .collect(); + Lindbladian::new(self.d, h, collapse) + } +} + +impl std::fmt::Debug for TimeDepLindbladian { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("TimeDepLindbladian") + .field("d", &self.d) + .field("num_collapse", &self.collapse_fns.len()) + .finish() + } +} + +// ============================================================================ +// Synthesis entry point +// ============================================================================ + +use crate::basis::PauliString; +use crate::matrix; +use crate::pauli_lindblad::PauliLindbladModel; + +/// Synthesize a Pauli-Lindblad model from a time-dependent noise +/// Lindbladian via time-slicing. Each slice's superoperator is the +/// interaction-frame-transformed L(t_mid) snapshot. +/// +/// `num_qubits` gives the Pauli enumeration dimension; `ideal_h` is the +/// (time-independent) ideal gate Hamiltonian used for the interaction +/// frame; `noise_td` is the time-dependent noise. +pub fn synthesize_superop_time_dep( + num_qubits: usize, + ideal_h: &Matrix, + noise_td: &TimeDepLindbladian, + tau_g: f64, + n_slices: usize, +) -> PauliLindbladModel { + assert!(n_slices >= 1); + let d = 1usize << num_qubits; + assert_eq!(ideal_h.len(), d * d); + assert_eq!(noise_td.d, d); + let d2 = d * d; + let dt = tau_g / n_slices as f64; + + // Product of per-slice propagators, newest on left. + let mut u_total = matrix::identity(d2); + for k in 0..n_slices { + let t_mid = (k as f64 + 0.5) * dt; + + // Evaluate time-dependent noise at this slice. + let lind_snap = noise_td.at(t_mid); + + // Interaction-frame transform. + let u_g = matrix::exp_minus_i_h_t(ideal_h, d, t_mid); + let u_g_dag = matrix::dag(&u_g, d); + let h_i = matrix::matmul( + &matrix::matmul(&u_g_dag, &lind_snap.hamiltonian, d), + &u_g, + d, + ); + let collapse_i: Vec<(Matrix, f64)> = lind_snap + .collapse + .iter() + .map(|(c, g)| (matrix::matmul(&matrix::matmul(&u_g_dag, c, d), &u_g, d), *g)) + .collect(); + + let lind_i = Lindbladian::new(d, h_i, collapse_i); + let l_super = lind_i.superoperator(); + let u_slice = matrix::expm(&matrix::scale(&l_super, Complex64::new(dt, 0.0)), d2); + u_total = matrix::matmul(&u_slice, &u_total, d2); + } + + // Apply to each Pauli, extract fidelities, Walsh-Hadamard inversion. + let paulis = PauliString::enumerate_nonidentity(num_qubits); + let alphas: Vec = paulis + .iter() + .map(|p| { + let p_mat = matrix::pauli_string_mat(p); + let vec_p = matrix::vec_of(&p_mat, d); + let vec_applied = matrix::matvec(&u_total, &vec_p, d2); + let applied = matrix::unvec(&vec_applied, d); + let inner = matrix::trace(&matrix::matmul(&p_mat, &applied, d), d); + let f_b = inner.re / d as f64; + assert!( + f_b > 0.1, + "Pauli fidelity {} too low for {:?}; noise outside weak regime", + f_b, + p, + ); + -f_b.ln() + }) + .collect(); + walsh_hadamard_invert(paulis, alphas, num_qubits) +} + +fn walsh_hadamard_invert( + paulis: Vec, + alphas: Vec, + n_qubits: usize, +) -> PauliLindbladModel { + let norm = 1.0 / (1usize << (2 * n_qubits)) as f64; + let rates: Vec = paulis + .iter() + .map(|k| { + let mut s = 0.0; + for (b, &a) in paulis.iter().zip(alphas.iter()) { + let sign = if k.symplectic_product(b) == 0 { + 1.0 + } else { + -1.0 + }; + s += sign * a; + } + (-norm * s).max(0.0) + }) + .collect(); + PauliLindbladModel::new(paulis, rates) +} diff --git a/exp/pecos-lindblad/tests/convergence.rs b/exp/pecos-lindblad/tests/convergence.rs new file mode 100644 index 000000000..b2a88b575 --- /dev/null +++ b/exp/pecos-lindblad/tests/convergence.rs @@ -0,0 +1,69 @@ +// 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. + +//! Simpson's-rule convergence test for `synthesize_numerical`. +//! +//! We sweep `N_STEPS` on a `CX_theta + AD+PD` fixture and verify that each +//! step count converges (within tol) to `DEFAULT_N_STEPS = 1024`. Ensures +//! that the default is neither needlessly large nor too small, and that +//! users tweaking `N_STEPS` know what to expect. +//! +//! For the current test parameters, convergence is already at `1e-12` +//! between `N = 64` and `N = 1024`, so `DEFAULT_N_STEPS = 1024` is +//! comfortably safe (~16x over-sampled). + +use approx::assert_abs_diff_eq; + +use pecos_lindblad::noise_models::ad_pd_2q; +use pecos_lindblad::{DEFAULT_N_STEPS, Gate, PauliString, synthesize_numerical}; + +fn cx_rates(n_steps: usize) -> Vec { + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + let noise = ad_pd_2q(100.0, 80.0, 120.0, 90.0); + let gate = Gate::cx_theta(omega, theta, noise); + let pl = synthesize_numerical(&gate, n_steps); + PauliString::enumerate_nonidentity(2) + .iter() + .map(|p| pl.rate(p)) + .collect() +} + +#[test] +fn simpson_converges_for_cx_theta() { + let reference = cx_rates(DEFAULT_N_STEPS); + // Simpson's 1/3 rule has error O(h^4). At N=64 over a single-oscillation + // interval we expect ~1e-10; at N=128, ~1e-12; at N>=256, machine eps. + let tolerances = [ + (16usize, 1e-6), + (32, 1e-8), + (64, 1e-10), + (128, 1e-12), + (256, 1e-13), + ]; + for (n, tol) in tolerances { + let result = cx_rates(n); + for (a, b) in result.iter().zip(reference.iter()) { + assert_abs_diff_eq!(a, b, epsilon = tol); + } + } +} + +#[test] +fn default_n_steps_matches_fine_grid() { + // DEFAULT_N_STEPS = 1024 should match N = 2048 to ~machine precision. + let a = cx_rates(DEFAULT_N_STEPS); + let b = cx_rates(2048); + for (x, y) in a.iter().zip(b.iter()) { + assert_abs_diff_eq!(x, y, epsilon = 1e-14); + } +} diff --git a/exp/pecos-lindblad/tests/cross_path.rs b/exp/pecos-lindblad/tests/cross_path.rs new file mode 100644 index 000000000..16ae23186 --- /dev/null +++ b/exp/pecos-lindblad/tests/cross_path.rs @@ -0,0 +1,78 @@ +// 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-path consistency: different entry points must agree on inputs +//! they both handle. +//! +//! - [`synthesize_identity_1q`] (fast) vs [`synthesize_numerical`] (Simpson) +//! for 1Q identity gate: should be bit-close (Simpson on constant +//! integrand is exact up to quadrature). +//! - [`synthesize_numerical`] for 2Q identity vs manual 1Q decomposition: +//! for independent qubits, rates should be consistent with the +//! single-qubit theory. + +use approx::assert_abs_diff_eq; + +use pecos_lindblad::noise_models::{ad_pd_1q, ad_pd_2q}; +use pecos_lindblad::{ + DEFAULT_N_STEPS, Gate, Pauli1, PauliString, synthesize_identity_1q, synthesize_numerical, +}; + +#[test] +fn fast_identity_matches_simpson_1q() { + let noise = ad_pd_1q(150.0, 120.0); + let tau_g = 2.0; + let gate = Gate::identity(1, noise, tau_g); + let fast = synthesize_identity_1q(&gate); + let simpson = synthesize_numerical(&gate, DEFAULT_N_STEPS); + for p in [Pauli1::X, Pauli1::Y, Pauli1::Z] { + let key = PauliString::single(p); + assert_abs_diff_eq!(fast.rate(&key), simpson.rate(&key), epsilon = 1e-14); + } +} + +#[test] +fn identity_2q_rates_agree_with_1q_independent_qubits() { + // For identity + AD+PD acting independently on two qubits, we expect + // weight-1 rates {lambda_ix, lambda_iy, lambda_iz, lambda_xi, lambda_yi, + // lambda_zi} to match the single-qubit predictions (paper line 812): + // lambda_{i·x} = lambda_{i·y} = beta_down_r * tau_g / 4 + // lambda_{i·z} = beta_phi_r * tau_g / 2 + // (mirror for the l qubit) + // Weight-2 rates should all be zero (no interaction between qubits). + let t1_l = 100.0; + let t2_l = 80.0; + let t1_r = 150.0; + let t2_r = 120.0; + let tau_g = 2.0; + let noise = ad_pd_2q(t1_l, t1_r, t2_l, t2_r); + let gate = Gate::identity(2, noise, tau_g); + let pl = synthesize_numerical(&gate, DEFAULT_N_STEPS); + + let bd_l = 1.0 / t1_l; + let bd_r = 1.0 / t1_r; + let bp_l = 1.0 / t2_l - 1.0 / (2.0 * t1_l); + let bp_r = 1.0 / t2_r - 1.0 / (2.0 * t1_r); + + let rate = |s: &str| pl.rate(&PauliString::from_label(s).unwrap()); + assert_abs_diff_eq!(rate("IX"), bd_r * tau_g / 4.0, epsilon = 1e-12); + assert_abs_diff_eq!(rate("IY"), bd_r * tau_g / 4.0, epsilon = 1e-12); + assert_abs_diff_eq!(rate("IZ"), bp_r * tau_g / 2.0, epsilon = 1e-12); + assert_abs_diff_eq!(rate("XI"), bd_l * tau_g / 4.0, epsilon = 1e-12); + assert_abs_diff_eq!(rate("YI"), bd_l * tau_g / 4.0, epsilon = 1e-12); + assert_abs_diff_eq!(rate("ZI"), bp_l * tau_g / 2.0, epsilon = 1e-12); + + // All weight-2 rates must be zero (no coupling). + for label in ["XX", "XY", "XZ", "YX", "YY", "YZ", "ZX", "ZY", "ZZ"] { + assert_abs_diff_eq!(rate(label), 0.0, epsilon = 1e-12); + } +} diff --git a/exp/pecos-lindblad/tests/custom_gate.rs b/exp/pecos-lindblad/tests/custom_gate.rs new file mode 100644 index 000000000..5563b78e1 --- /dev/null +++ b/exp/pecos-lindblad/tests/custom_gate.rs @@ -0,0 +1,101 @@ +// 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. + +//! Smoke test for `Gate::from_hamiltonian` — the general escape hatch +//! that lets users build gates not in the named catalog. Exercises the +//! `matrix::expm` fallback for a non-structured 4x4 Hamiltonian. + +use approx::assert_abs_diff_eq; +use num_complex::Complex64; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::{ + DEFAULT_N_STEPS, Gate, Lindbladian, Pauli1, PauliString, synthesize_exact_unitary, + synthesize_numerical, +}; + +/// iSWAP_theta generator: `H_g = (omega/2)(XX + YY)` (4x4 Hermitian, +/// non-diagonal, non-block-diagonal in computational basis -- hits +/// the `expm` fallback path). +fn iswap_hamiltonian(omega: f64) -> Matrix { + let x = matrix::pauli_1q(Pauli1::X); + let y = matrix::pauli_1q(Pauli1::Y); + let xx = matrix::kron(&x, &x, 2, 2); + let yy = matrix::kron(&y, &y, 2, 2); + matrix::scale(&matrix::add(&xx, &yy), Complex64::new(omega / 2.0, 0.0)) +} + +#[test] +fn iswap_theta_reduces_to_identity_at_zero_theta() { + // At theta=0 (or tau_g=0), the gate Hamiltonian has no time to act, + // so the result should match identity+AD+PD rates exactly. + let d = 2; + let i2 = matrix::identity(2); + let sm = matrix::sigma_minus(); + let z1 = matrix::pauli_1q(Pauli1::Z); + let sm_l = matrix::kron(&sm, &i2, 2, 2); + let sm_r = matrix::kron(&i2, &sm, 2, 2); + let z_l = matrix::kron(&z1, &i2, 2, 2); + let z_r = matrix::kron(&i2, &z1, 2, 2); + let beta_down = 1e-4; + let beta_phi = 2e-4; + let _ = d; + + let collapse: Vec<(Matrix, f64)> = vec![ + (sm_l, beta_down), + (sm_r, beta_down), + (z_l, beta_phi / 2.0), + (z_r, beta_phi / 2.0), + ]; + let noise = Lindbladian::new(4, matrix::zeros(4), collapse); + let tau_g = 0.0; // Degenerate duration. + let h_iswap = iswap_hamiltonian(1.0); + let gate = Gate::from_hamiltonian("iswap_0", 2, h_iswap, noise, tau_g); + + let pl = synthesize_numerical(&gate, DEFAULT_N_STEPS); + // At tau_g=0 everything should vanish. + for ps in PauliString::enumerate_nonidentity(2) { + assert_abs_diff_eq!(pl.rate(&ps), 0.0, epsilon = 1e-14); + } +} + +#[test] +fn custom_hamiltonian_coherent_noise_produces_nonzero_rates() { + // Construct a non-structured 2Q Hamiltonian H_g = XX + YY (iSWAP + // generator) with coherent IZ phase noise. Verify synthesis runs + // and produces some non-zero rate (exercises expm fallback). + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + let tau_g = theta / omega; + let h_g = iswap_hamiltonian(omega); + + let i2 = matrix::identity(2); + let z1 = matrix::pauli_1q(Pauli1::Z); + let iz = matrix::kron(&i2, &z1, 2, 2); + let delta = 1e-4; + let h_delta = matrix::scale(&iz, Complex64::new(delta / 2.0, 0.0)); + let noise = Lindbladian::new(4, h_delta, Vec::new()); + + let gate = Gate::from_hamiltonian("iswap_xy", 2, h_g, noise, tau_g); + let pl = synthesize_exact_unitary(&gate); + + // Some rate should be non-zero; total rate in correct order of magnitude. + let total = pl.total_rate(); + let expected_scale = (delta / omega).powi(2); + assert!(total > 0.0, "expected non-zero rates, got total={}", total); + assert!( + total < 10.0 * expected_scale, + "total rate {} exceeds scale {} by >10x -- higher-order term leak?", + total, + expected_scale, + ); +} diff --git a/exp/pecos-lindblad/tests/cx_theta_2q.rs b/exp/pecos-lindblad/tests/cx_theta_2q.rs new file mode 100644 index 000000000..30ce651b5 --- /dev/null +++ b/exp/pecos-lindblad/tests/cx_theta_2q.rs @@ -0,0 +1,173 @@ +// 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. + +//! Parity test: 2-qubit CX_theta gate under independent AD + PD on each +//! qubit vs closed-form leading-order results from arXiv:2502.03462 +//! eqs. 929-956 (appendix SubApp:CX_th+AD+PD). +//! +//! CX_theta is the showcase gate of the paper. Unlike CZ_theta, AD and PD +//! contributions *mix* on `lambda_{iy, iz, zy, zz}` (each has both a +//! `beta_down_r/omega` and `beta_phi_r/omega` term). +//! +//! Paper closed forms (10 non-zero rates): +//! lambda_ix = (theta/4)(beta_down_r/omega) +//! lambda_iy = [(12t+8s2+s4)/128] beta_down_r/omega +//! + [(4t-s4)/64] beta_phi_r/omega +//! lambda_iz = [(4t-s4)/128] beta_down_r/omega +//! + [(12t+8s2+s4)/64] beta_phi_r/omega +//! lambda_xi = lambda_yi = [(2t+s2)/16] beta_down_l/omega +//! lambda_xx = lambda_yx = [(2t-s2)/16] beta_down_l/omega +//! lambda_zi = (theta/2)(beta_phi_l/omega) +//! lambda_zy = [(12t-8s2+s4)/128] beta_down_r/omega +//! + [(4t-s4)/64] beta_phi_r/omega +//! lambda_zz = [(4t-s4)/128] beta_down_r/omega +//! + [(12t-8s2+s4)/64] beta_phi_r/omega +//! +//! where s2 = sin(2 theta), s4 = sin(4 theta). +//! +//! 5 rates are zero to leading order: XY, XZ, YY, YZ, ZX. + +use approx::assert_abs_diff_eq; +use num_complex::Complex64; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::{ + DEFAULT_N_STEPS, Gate, Lindbladian, Pauli1, PauliString, synthesize_numerical, +}; + +fn two_qubit_ad_plus_pd( + beta_down_l: f64, + beta_down_r: f64, + beta_phi_l: f64, + beta_phi_r: f64, +) -> Lindbladian { + let d = 4; + let i2 = matrix::identity(2); + let sm = matrix::sigma_minus(); + let z = matrix::pauli_1q(Pauli1::Z); + let sm_l = matrix::kron(&sm, &i2, 2, 2); + let sm_r = matrix::kron(&i2, &sm, 2, 2); + let z_l = matrix::kron(&z, &i2, 2, 2); + let z_r = matrix::kron(&i2, &z, 2, 2); + + let collapse: Vec<(Matrix, f64)> = vec![ + (sm_l, beta_down_l), + (sm_r, beta_down_r), + (z_l, beta_phi_l / 2.0), + (z_r, beta_phi_r / 2.0), + ]; + let zero_ham: Matrix = vec![Complex64::new(0.0, 0.0); d * d]; + Lindbladian::new(d, zero_ham, collapse) +} + +#[allow(clippy::too_many_arguments)] +fn paper_cx_rate( + label: &str, + theta: f64, + omega: f64, + bd_l: f64, + bd_r: f64, + bp_l: f64, + bp_r: f64, +) -> f64 { + let s2 = (2.0 * theta).sin(); + let s4 = (4.0 * theta).sin(); + let f_amp_plus = (2.0 * theta + s2) / 16.0; + let f_amp_minus = (2.0 * theta - s2) / 16.0; + let f_dbl_plus = (12.0 * theta + 8.0 * s2 + s4) / 128.0; + let f_dbl_minus = (12.0 * theta - 8.0 * s2 + s4) / 128.0; + let f_anti_4 = (4.0 * theta - s4) / 64.0; + let f_anti_128 = (4.0 * theta - s4) / 128.0; + match label { + "IX" => (theta / 4.0) * (bd_r / omega), + "IY" => f_dbl_plus * (bd_r / omega) + f_anti_4 * (bp_r / omega), + "IZ" => { + f_anti_128 * (bd_r / omega) + (12.0 * theta + 8.0 * s2 + s4) / 64.0 * (bp_r / omega) + } + "XI" | "YI" => f_amp_plus * (bd_l / omega), + "XX" | "YX" => f_amp_minus * (bd_l / omega), + "ZI" => (theta / 2.0) * (bp_l / omega), + "ZY" => f_dbl_minus * (bd_r / omega) + f_anti_4 * (bp_r / omega), + "ZZ" => { + f_anti_128 * (bd_r / omega) + (12.0 * theta - 8.0 * s2 + s4) / 64.0 * (bp_r / omega) + } + "XY" | "XZ" | "YY" | "YZ" | "ZX" => 0.0, + _ => panic!("unknown label {}", label), + } +} + +fn run_cx(theta: f64, omega: f64, bd_l: f64, bd_r: f64, bp_l: f64, bp_r: f64, tol: f64) { + let noise = two_qubit_ad_plus_pd(bd_l, bd_r, bp_l, bp_r); + let gate = Gate::cx_theta(omega, theta, noise); + let pl = synthesize_numerical(&gate, DEFAULT_N_STEPS); + + let all_labels = [ + "IX", "IY", "IZ", "XI", "XX", "XY", "XZ", "YI", "YX", "YY", "YZ", "ZI", "ZX", "ZY", "ZZ", + ]; + for label in all_labels { + let got = pl.rate(&PauliString::from_label(label).unwrap()); + let expected = paper_cx_rate(label, theta, omega, bd_l, bd_r, bp_l, bp_r); + assert_abs_diff_eq!(got, expected, epsilon = tol); + } +} + +#[test] +fn cx_theta_ad_plus_pd_pi_over_4() { + // Paper's showcase angle (sqrt(CX) = CX_{pi/4}). + run_cx( + std::f64::consts::FRAC_PI_4, + 1.0, + 1e-4, // AD l + 2e-4, // AD r + 5e-5, // PD l + 7e-5, // PD r + 1e-8, + ); +} + +#[test] +fn cx_theta_ad_plus_pd_pi_over_2() { + // Clifford: full CNOT. Exercises sin(4 theta) = sin(2pi) = 0 terms. + run_cx( + std::f64::consts::FRAC_PI_2, + 1.0, + 1e-4, + 1.5e-4, + 3e-5, + 4e-5, + 1e-8, + ); +} + +#[test] +fn cx_theta_ad_only() { + run_cx(std::f64::consts::FRAC_PI_3, 1.5, 3e-4, 1e-4, 0.0, 0.0, 1e-8); +} + +#[test] +fn cx_theta_pd_only() { + run_cx(std::f64::consts::FRAC_PI_4, 1.0, 0.0, 0.0, 2e-4, 3e-4, 1e-8); +} + +#[test] +fn cx_theta_symmetric_beta_down_symmetric_pd() { + // Exercise all non-zero rates simultaneously, symmetric noise case. + run_cx( + std::f64::consts::FRAC_PI_4, + 1.0, + 2e-4, + 2e-4, + 1e-4, + 1e-4, + 1e-8, + ); +} diff --git a/exp/pecos-lindblad/tests/cz_theta_2q.rs b/exp/pecos-lindblad/tests/cz_theta_2q.rs new file mode 100644 index 000000000..892b87a7f --- /dev/null +++ b/exp/pecos-lindblad/tests/cz_theta_2q.rs @@ -0,0 +1,173 @@ +// 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. + +//! Parity test: 2-qubit CZ_theta gate under independent AD + PD on each +//! qubit vs closed-form leading-order results from arXiv:2502.03462 +//! eqs. 896-906 (appendix SubApp:CZ_th+AD+PD). +//! +//! String index convention: leftmost factor is the "l" (left) qubit. +//! "iz" == I (x) Z +//! "zx" == Z (x) X +//! +//! Paper closed forms (all others vanish to leading order): +//! lambda_iz = (theta/2) * beta_phi_r / omega_cz +//! lambda_zi = (theta/2) * beta_phi_l / omega_cz +//! lambda_ix = lambda_iy = (2t+sin2t)/16 * beta_down_r / omega_cz +//! lambda_xi = lambda_yi = (2t+sin2t)/16 * beta_down_l / omega_cz +//! lambda_zx = lambda_zy = (2t-sin2t)/16 * beta_down_r / omega_cz +//! lambda_xz = lambda_yz = (2t-sin2t)/16 * beta_down_l / omega_cz + +use approx::assert_abs_diff_eq; +use num_complex::Complex64; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::{ + DEFAULT_N_STEPS, Gate, Lindbladian, Pauli1, PauliString, synthesize_numerical, +}; + +fn two_qubit_ad_plus_pd( + beta_down_l: f64, + beta_down_r: f64, + beta_phi_l: f64, + beta_phi_r: f64, +) -> Lindbladian { + let d = 4; + let i2 = matrix::identity(2); + let sm = matrix::sigma_minus(); + let z = matrix::pauli_1q(Pauli1::Z); + // Kronecker: (l) ⊗ (r) with l = left qubit = index 0. + let sm_l = matrix::kron(&sm, &i2, 2, 2); + let sm_r = matrix::kron(&i2, &sm, 2, 2); + let z_l = matrix::kron(&z, &i2, 2, 2); + let z_r = matrix::kron(&i2, &z, 2, 2); + + let collapse: Vec<(Matrix, f64)> = vec![ + (sm_l, beta_down_l), + (sm_r, beta_down_r), + (z_l, beta_phi_l / 2.0), + (z_r, beta_phi_r / 2.0), + ]; + let zero_ham: Matrix = vec![Complex64::new(0.0, 0.0); d * d]; + Lindbladian::new(d, zero_ham, collapse) +} + +#[derive(Debug, Clone, Copy)] +struct CzExpected { + iz: f64, + zi: f64, + ix: f64, + iy: f64, + xi: f64, + yi: f64, + zx: f64, + zy: f64, + xz: f64, + yz: f64, +} + +fn paper_closed_form( + theta: f64, + omega_cz: f64, + beta_down_l: f64, + beta_down_r: f64, + beta_phi_l: f64, + beta_phi_r: f64, +) -> CzExpected { + let two_t = 2.0 * theta; + let sin_2t = two_t.sin(); + let amp_r = (two_t + sin_2t) / 16.0 * (beta_down_r / omega_cz); + let amp_l = (two_t + sin_2t) / 16.0 * (beta_down_l / omega_cz); + let anti_r = (two_t - sin_2t) / 16.0 * (beta_down_r / omega_cz); + let anti_l = (two_t - sin_2t) / 16.0 * (beta_down_l / omega_cz); + CzExpected { + iz: (theta / 2.0) * (beta_phi_r / omega_cz), + zi: (theta / 2.0) * (beta_phi_l / omega_cz), + ix: amp_r, + iy: amp_r, + xi: amp_l, + yi: amp_l, + zx: anti_r, + zy: anti_r, + xz: anti_l, + yz: anti_l, + } +} + +fn run_cz( + theta: f64, + omega_cz: f64, + beta_down_l: f64, + beta_down_r: f64, + beta_phi_l: f64, + beta_phi_r: f64, + tol: f64, +) { + let noise = two_qubit_ad_plus_pd(beta_down_l, beta_down_r, beta_phi_l, beta_phi_r); + let gate = Gate::cz_theta(omega_cz, theta, noise); + let pl = synthesize_numerical(&gate, DEFAULT_N_STEPS); + + let exp = paper_closed_form( + theta, + omega_cz, + beta_down_l, + beta_down_r, + beta_phi_l, + beta_phi_r, + ); + let rate = |s: &str| pl.rate(&PauliString::from_label(s).unwrap()); + + assert_abs_diff_eq!(rate("IZ"), exp.iz, epsilon = tol); + assert_abs_diff_eq!(rate("ZI"), exp.zi, epsilon = tol); + assert_abs_diff_eq!(rate("IX"), exp.ix, epsilon = tol); + assert_abs_diff_eq!(rate("IY"), exp.iy, epsilon = tol); + assert_abs_diff_eq!(rate("XI"), exp.xi, epsilon = tol); + assert_abs_diff_eq!(rate("YI"), exp.yi, epsilon = tol); + assert_abs_diff_eq!(rate("ZX"), exp.zx, epsilon = tol); + assert_abs_diff_eq!(rate("ZY"), exp.zy, epsilon = tol); + assert_abs_diff_eq!(rate("XZ"), exp.xz, epsilon = tol); + assert_abs_diff_eq!(rate("YZ"), exp.yz, epsilon = tol); + + // All remaining 5 Paulis should be (numerically) zero at leading order. + for label in ["XX", "XY", "YX", "YY", "ZZ"] { + assert_abs_diff_eq!(rate(label), 0.0, epsilon = tol); + } +} + +#[test] +fn cz_theta_ad_plus_pd_pi_over_4() { + // Weak noise beta/omega ~ 1e-4 => leading-order match to ~1e-8. + run_cz( + std::f64::consts::FRAC_PI_4, + 1.0, + 1e-4, // AD l + 2e-4, // AD r + 5e-5, // PD l + 7e-5, // PD r + 1e-8, + ); +} + +#[test] +fn cz_theta_ad_plus_pd_pi_over_2() { + // theta=pi/2 is Clifford: paper predicts 4-fold degeneracies. + run_cz(std::f64::consts::FRAC_PI_2, 1.0, 1e-4, 1e-4, 0.0, 0.0, 1e-8); +} + +#[test] +fn cz_theta_ad_only() { + run_cz(std::f64::consts::FRAC_PI_3, 1.5, 3e-4, 1e-4, 0.0, 0.0, 1e-8); +} + +#[test] +fn cz_theta_pd_only() { + run_cz(std::f64::consts::FRAC_PI_4, 1.0, 0.0, 0.0, 2e-4, 3e-4, 1e-8); +} diff --git a/exp/pecos-lindblad/tests/dem_stab_integration.rs b/exp/pecos-lindblad/tests/dem_stab_integration.rs new file mode 100644 index 000000000..1e2337edf --- /dev/null +++ b/exp/pecos-lindblad/tests/dem_stab_integration.rs @@ -0,0 +1,152 @@ +// 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. + +//! Scaffolded integration: synthesize a Pauli-Lindblad model from a +//! physical Lindbladian, collapse to scalar p1/p2 (lossy), and feed the +//! scalars to the existing uniform-depolarizing `DemStabSim`. +//! +//! **This is a scaffold, not the full bridge.** The paper's real payoff is +//! per-location per-Pauli rates, but `pecos-qec::fault_tolerance::dem_builder` +//! currently accepts only uniform-depolarizing `NoiseConfig { p1, p2, +//! p_meas, p_prep }` (4 scalar probabilities). A proper integration requires +//! generalizing `NoiseConfig` to accept a `PauliLindbladModel` per gate +//! type; that change is out of scope for the `pecos-lindblad` crate and is +//! tracked in `design/lindblad_sim_skeleton.md`. +//! +//! What this test proves *today*: +//! - Lindbladian + duration -> PauliLindbladModel works end-to-end. +//! - Summary helpers (`total_rate`, `rate_at_weight`) produce sensible numbers. +//! - Output scalars are in a range that `DemStabSim` will accept. +//! - DemStabSim runs with those scalars and returns shot batches. + +use num_complex::Complex64; +use rand::SeedableRng; +use rand::rngs::SmallRng; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::{ + DEFAULT_N_STEPS, Gate, Lindbladian, Pauli1, synthesize_identity_1q, synthesize_numerical, +}; +use pecos_qec::dem_stab::DemStabSim; +use pecos_qec::fault_tolerance::dem_builder::{DetectorDef, NoiseConfig}; +use pecos_quantum::DagCircuit; + +fn ad_plus_pd_1q(beta_down: f64, beta_phi: f64) -> Lindbladian { + let d = 2; + let hamiltonian = matrix::zeros(d); + let collapse: Vec<(Matrix, f64)> = vec![ + (matrix::sigma_minus(), beta_down), + (matrix::pauli_1q(Pauli1::Z), beta_phi / 2.0), + ]; + Lindbladian::new(d, hamiltonian, collapse) +} + +fn ad_plus_pd_2q(beta_down: f64, beta_phi: f64) -> Lindbladian { + let d = 4; + let i2 = matrix::identity(2); + let sm = matrix::sigma_minus(); + let z = matrix::pauli_1q(Pauli1::Z); + let sm_l = matrix::kron(&sm, &i2, 2, 2); + let sm_r = matrix::kron(&i2, &sm, 2, 2); + let z_l = matrix::kron(&z, &i2, 2, 2); + let z_r = matrix::kron(&i2, &z, 2, 2); + let collapse: Vec<(Matrix, f64)> = vec![ + (sm_l, beta_down), + (sm_r, beta_down), + (z_l, beta_phi / 2.0), + (z_r, beta_phi / 2.0), + ]; + let zero_ham: Matrix = vec![Complex64::new(0.0, 0.0); d * d]; + Lindbladian::new(d, zero_ham, collapse) +} + +#[test] +fn lindblad_derived_noise_config_feeds_dem_stab_sim() { + // Step 1: physical noise parameters from a hypothetical device. + let beta_down = 1e-4; // per time unit, e.g. inverse of T1 + let beta_phi = 2e-4; // dephasing + let tau_1q = 40.0; // 1Q gate duration + let omega_cx = 1.0; + let theta = std::f64::consts::FRAC_PI_2; // full CNOT + + // Step 2: synthesize Pauli-Lindblad models for each gate family. + let pl_1q = synthesize_identity_1q(&Gate::identity( + 1, + ad_plus_pd_1q(beta_down, beta_phi), + tau_1q, + )); + let pl_cx = synthesize_numerical( + &Gate::cx_theta(omega_cx, theta, ad_plus_pd_2q(beta_down, beta_phi)), + DEFAULT_N_STEPS, + ); + + // Sanity-check the summaries. + let total_1q = pl_1q.total_rate(); + let total_2q = pl_cx.total_rate(); + assert!( + total_1q > 0.0 && total_1q < 0.1, + "1Q total rate out of range: {}", + total_1q + ); + assert!( + total_2q > 0.0 && total_2q < 0.1, + "2Q total rate out of range: {}", + total_2q + ); + // For the 1Q identity with AD+PD, only weight-1 rates should be non-zero. + assert!((pl_1q.rate_at_weight(1) - total_1q).abs() < 1e-12); + // For CX_theta, both weight-1 and weight-2 rates exist. + assert!(pl_cx.rate_at_weight(1) > 0.0); + assert!(pl_cx.rate_at_weight(2) > 0.0); + + // Step 3: lossy collapse to scalar p1, p2 for DemStabSim. + // Caveat: DemStabSim treats p1 as uniform depolarizing (X/Y/Z equal). + // For asymmetric AD+PD the numbers here are order-of-magnitude correct + // but lose per-Pauli structure. This is the gap that motivates the + // proper integration (generalize NoiseConfig to carry a PL model). + let p1 = pl_1q.total_rate(); + let p2 = pl_cx.total_rate(); + let noise = NoiseConfig::new(p1, p2, 0.0, 0.0); + + // Step 4: build a tiny repetition-code-style circuit and sample. + let mut dag = DagCircuit::new(); + dag.pz(&[2]); + dag.cx(&[(0, 2)]); + dag.cx(&[(1, 2)]); + dag.mz(&[2]); + + let sim = DemStabSim::builder() + .circuit(dag) + .noise(noise) + .detectors(vec![DetectorDef::new(0).with_records([-1])]) + .build() + .expect("DemStabSim build"); + + // Step 5: shots flow through. + let mut rng = SmallRng::seed_from_u64(42); + let batch = sim.sample_batch(500, &mut rng); + assert_eq!(batch.detector_flips.len(), 500); + assert_eq!(batch.detector_flips[0].len(), 1); +} + +#[test] +fn lindblad_scalar_collapse_is_order_of_magnitude_sane() { + // For 1Q identity under AD only (no PD), paper closed form: + // lambda_x = lambda_y = beta_down * tau / 4, lambda_z = 0 + // total = beta_down * tau / 2. + // The lossy scalar collapse should report the same total. + let beta_down = 3e-4; + let tau = 100.0; + let pl = synthesize_identity_1q(&Gate::identity(1, ad_plus_pd_1q(beta_down, 0.0), tau)); + let expected = beta_down * tau / 2.0; + assert!((pl.total_rate() - expected).abs() < 1e-12); +} diff --git a/exp/pecos-lindblad/tests/diff_helpers.rs b/exp/pecos-lindblad/tests/diff_helpers.rs new file mode 100644 index 000000000..2490840cb --- /dev/null +++ b/exp/pecos-lindblad/tests/diff_helpers.rs @@ -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. + +//! Tests for validation-oriented diff helpers on `PauliLindbladModel`. + +use approx::assert_abs_diff_eq; + +use pecos_lindblad::{PauliLindbladModel, PauliString}; + +fn model(entries: &[(&str, f64)]) -> PauliLindbladModel { + let supports: Vec<_> = entries + .iter() + .map(|(s, _)| PauliString::from_label(s).unwrap()) + .collect(); + let rates: Vec<_> = entries.iter().map(|(_, r)| *r).collect(); + PauliLindbladModel::new(supports, rates) +} + +#[test] +fn diff_is_sorted_by_absolute_residual() { + let pred = model(&[("IX", 0.001), ("IZ", 0.005), ("XI", 0.002)]); + let meas = model(&[("IX", 0.0012), ("IZ", 0.008), ("XI", 0.002)]); + let d = pred.diff(&meas); + // IZ has largest absolute residual (0.003), IX next (0.0002), XI last (0). + assert_eq!(format!("{}", d[0].0), "IZ"); + assert_eq!(format!("{}", d[1].0), "IX"); + assert_eq!(format!("{}", d[2].0), "XI"); + assert_abs_diff_eq!(d[0].3, -0.003, epsilon = 1e-12); +} + +#[test] +fn residual_l2_and_max() { + let a = model(&[("X", 0.01), ("Y", 0.02), ("Z", 0.03)]); + let b = model(&[("X", 0.02), ("Y", 0.00), ("Z", 0.03)]); + // Differences: X=-0.01, Y=+0.02, Z=0. L2 = sqrt(0.0001 + 0.0004) = sqrt(5)*0.01. + assert_abs_diff_eq!(a.residual_l2(&b), (5.0_f64).sqrt() * 0.01, epsilon = 1e-12); + let (worst_p, worst_r) = a.max_residual(&b).unwrap(); + assert_eq!(format!("{}", worst_p), "Y"); + assert_abs_diff_eq!(worst_r, 0.02, epsilon = 1e-12); +} + +#[test] +fn residual_by_weight_classifies_correctly() { + let a = model(&[("IX", 0.001), ("IY", 0.001), ("ZZ", 0.005), ("IZZ", 0.002)]); + let b = model(&[("IX", 0.002), ("IY", 0.001), ("ZZ", 0.009), ("IZZ", 0.0025)]); + let by_w = a.residual_by_weight(&b); + // Differences: IX=-0.001 (wt 1), IY=0 (wt 1), ZZ=-0.004 (wt 2), IZZ=-0.0005 (wt 2). + let w1 = by_w.iter().find(|(w, _)| *w == 1).unwrap().1; + let w2 = by_w.iter().find(|(w, _)| *w == 2).unwrap().1; + assert_abs_diff_eq!(w1, 0.001, epsilon = 1e-12); + assert_abs_diff_eq!(w2, 0.0045, epsilon = 1e-12); +} + +#[test] +fn diagnose_flags_large_weight_2_residual() { + let pred = model(&[("IX", 0.001), ("IZ", 0.005)]); + let meas = model(&[("IX", 0.001), ("IZ", 0.005), ("ZZ", 0.01)]); + // Predicted model missing ZZ -- diagnose should flag weight-2 residual. + let diagnoses = pred.diagnose_gap(&meas, 1e-5); + assert!( + diagnoses.iter().any(|m| m.contains("weight-2")), + "expected weight-2 diagnosis in {:?}", + diagnoses + ); + assert!( + diagnoses.iter().any(|m| m.contains("ZZ")), + "expected ZZ mention in {:?}", + diagnoses + ); +} + +#[test] +fn diagnose_quiet_when_models_agree() { + let pred = model(&[("X", 0.001), ("Z", 0.002)]); + let meas = model(&[("X", 0.001), ("Z", 0.002)]); + let diagnoses = pred.diagnose_gap(&meas, 1e-10); + assert!( + diagnoses.is_empty(), + "expected no diagnoses, got {:?}", + diagnoses + ); +} + +#[test] +fn union_of_supports_considered() { + // a has X, b has Z; diff should include both with appropriate zero. + let a = model(&[("X", 0.003)]); + let b = model(&[("Z", 0.005)]); + let d = a.diff(&b); + assert_eq!(d.len(), 2); + // Largest |residual| is Z = 0 - 0.005 = -0.005. + assert_eq!(format!("{}", d[0].0), "Z"); + assert_abs_diff_eq!(d[0].3, -0.005, epsilon = 1e-12); +} diff --git a/exp/pecos-lindblad/tests/expm_smoke.rs b/exp/pecos-lindblad/tests/expm_smoke.rs new file mode 100644 index 000000000..2c1b3919a --- /dev/null +++ b/exp/pecos-lindblad/tests/expm_smoke.rs @@ -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. + +//! Smoke tests for `matrix::expm` (general Taylor + scaling + squaring). + +use num_complex::Complex64; + +use pecos_lindblad::Pauli1; +use pecos_lindblad::matrix::{self, Matrix}; + +fn assert_close(a: &Matrix, b: &Matrix, tol: f64) { + assert_eq!(a.len(), b.len(), "matrix size mismatch"); + for i in 0..a.len() { + let delta = (a[i] - b[i]).norm(); + assert!( + delta < tol, + "entry {}: |{:?} - {:?}| = {} > {}", + i, + a[i], + b[i], + delta, + tol + ); + } +} + +#[test] +fn expm_of_zero_is_identity() { + for d in [2, 3, 4, 8] { + let z = matrix::zeros(d); + let result = matrix::expm(&z, d); + assert_close(&result, &matrix::identity(d), 1e-14); + } +} + +#[test] +fn expm_of_diagonal_is_elementwise_exp() { + let d = 4; + let mut m = matrix::zeros(d); + m[0] = Complex64::new(0.5, 0.0); + m[d + 1] = Complex64::new(-0.3, 0.0); + m[2 * d + 2] = Complex64::new(0.0, 1.2); + m[3 * d + 3] = Complex64::new(-0.1, -0.4); + let result = matrix::expm(&m, d); + let mut expected = matrix::zeros(d); + for i in 0..d { + expected[i * d + i] = m[i * d + i].exp(); + } + assert_close(&result, &expected, 1e-12); +} + +#[test] +fn expm_agrees_with_1q_bloch_on_traceless() { + // H = 1.3 * X + 0.7 * Y - 0.4 * Z (traceless Hermitian 2x2). + // Compare expm(-i * H * t) against exp_minus_i_h_t_1q_traceless. + let d = 2; + let x = matrix::pauli_1q(Pauli1::X); + let y = matrix::pauli_1q(Pauli1::Y); + let z = matrix::pauli_1q(Pauli1::Z); + let h = matrix::add( + &matrix::add( + &matrix::scale(&x, Complex64::new(1.3, 0.0)), + &matrix::scale(&y, Complex64::new(0.7, 0.0)), + ), + &matrix::scale(&z, Complex64::new(-0.4, 0.0)), + ); + for t in [0.1, 0.7, 1.5] { + let bloch = matrix::exp_minus_i_h_t_1q_traceless(&h, t); + let via_expm = matrix::expm(&matrix::scale(&h, Complex64::new(0.0, -t)), d); + assert_close(&bloch, &via_expm, 1e-11); + } +} + +#[test] +fn expm_preserves_unitarity_for_hermitian_input() { + // For any Hermitian H, U = exp(-i H t) should satisfy U U^dag = I. + let d = 4; + let i2 = matrix::identity(2); + let x = matrix::pauli_1q(Pauli1::X); + let z = matrix::pauli_1q(Pauli1::Z); + let ix = matrix::kron(&i2, &x, 2, 2); + let zx = matrix::kron(&z, &x, 2, 2); + let h = matrix::sub(&ix, &zx); // CX-style Hermitian + let t = 0.47; + let u = matrix::expm(&matrix::scale(&h, Complex64::new(0.0, -t)), d); + let u_udag = matrix::matmul(&u, &matrix::dag(&u, d), d); + assert_close(&u_udag, &matrix::identity(d), 1e-11); +} + +#[test] +fn expm_fallback_used_for_non_structured_4x4() { + // Construct a 4x4 Hermitian that is NOT diagonal and NOT 2x2-block-diag. + // H = IX + XI + YZ (mixes all quadrants). + let d = 4; + let i2 = matrix::identity(2); + let x = matrix::pauli_1q(Pauli1::X); + let y = matrix::pauli_1q(Pauli1::Y); + let z = matrix::pauli_1q(Pauli1::Z); + let ix = matrix::kron(&i2, &x, 2, 2); + let xi = matrix::kron(&x, &i2, 2, 2); + let yz = matrix::kron(&y, &z, 2, 2); + let h = matrix::add(&matrix::add(&ix, &xi), &yz); + + assert!(!matrix::is_diagonal(&h, d, 1e-14)); + assert!(!matrix::is_2x2_block_diagonal(&h, 1e-14)); + + // Should not panic (falls through to general expm). + let u = matrix::exp_minus_i_h_t(&h, d, 0.3); + // Unitarity check. + let u_udag = matrix::matmul(&u, &matrix::dag(&u, d), d); + assert_close(&u_udag, &matrix::identity(d), 1e-11); +} diff --git a/exp/pecos-lindblad/tests/four_qubit_smoke.rs b/exp/pecos-lindblad/tests/four_qubit_smoke.rs new file mode 100644 index 000000000..823508476 --- /dev/null +++ b/exp/pecos-lindblad/tests/four_qubit_smoke.rs @@ -0,0 +1,185 @@ +// 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. + +//! 4-qubit smoke test: exercises the `d=16` matrix-exp path and 255-Pauli +//! enumeration in the synthesis pipeline. +//! +//! Case: 4Q identity gate with AD+PD noise on a single qubit. Expected +//! result (by independence): only the 3 weight-1 rates on that qubit are +//! non-zero, all other 252 rates vanish. + +use approx::assert_abs_diff_eq; +use num_complex::Complex64; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::{ + DEFAULT_N_STEPS, Gate, Lindbladian, Pauli1, PauliString, synthesize_exact_unitary, + synthesize_numerical, +}; + +fn kron_all(ops: &[&Matrix]) -> Matrix { + // Left-associative Kronecker fold over a non-empty slice. + let mut acc = ops[0].clone(); + let mut d = (ops[0].len() as f64).sqrt() as usize; + for op in &ops[1..] { + let d2 = (op.len() as f64).sqrt() as usize; + acc = matrix::kron(&acc, op, d, d2); + d *= d2; + } + acc +} + +#[test] +fn three_qubit_identity_ad_on_one_qubit_fast_smoke() { + let d = 8; + let i2 = matrix::identity(2); + let sm = matrix::sigma_minus(); + let z = matrix::pauli_1q(Pauli1::Z); + + let beta_down = 1e-3; + let beta_phi = 2e-3; + let tau_g = 5.0; + + let sm_q1 = kron_all(&[&i2, &sm, &i2]); + let z_q1 = kron_all(&[&i2, &z, &i2]); + + let collapse: Vec<(Matrix, f64)> = vec![(sm_q1, beta_down), (z_q1, beta_phi / 2.0)]; + let hamiltonian: Matrix = vec![Complex64::new(0.0, 0.0); d * d]; + let noise = Lindbladian::new(d, hamiltonian, collapse); + + let gate = Gate::identity(3, noise, tau_g); + let pl = synthesize_numerical(&gate, DEFAULT_N_STEPS); + let pl_coarse = synthesize_numerical(&gate, 2); + + let rate = |s: &str| pl.rate(&PauliString::from_label(s).unwrap()); + assert_abs_diff_eq!(rate("IXI"), beta_down * tau_g / 4.0, epsilon = 1e-10); + assert_abs_diff_eq!(rate("IYI"), beta_down * tau_g / 4.0, epsilon = 1e-10); + assert_abs_diff_eq!(rate("IZI"), beta_phi * tau_g / 2.0, epsilon = 1e-10); + for ps in PauliString::enumerate_nonidentity(3) { + assert_abs_diff_eq!(pl.rate(&ps), pl_coarse.rate(&ps), epsilon = 1e-14); + } + + for ps in PauliString::enumerate_nonidentity(3) { + let label = format!("{}", ps); + if label == "IXI" || label == "IYI" || label == "IZI" { + continue; + } + assert_abs_diff_eq!(pl.rate(&ps), 0.0, epsilon = 1e-10); + } +} + +#[test] +fn three_qubit_identity_coherent_zzz_fast_smoke() { + let d = 8; + let tau_g = 5.0; + let delta = 1e-4; + + let z = matrix::pauli_1q(Pauli1::Z); + let zzz = kron_all(&[&z, &z, &z]); + let h_delta = matrix::scale(&zzz, Complex64::new(delta / 2.0, 0.0)); + let noise = Lindbladian::new(d, h_delta, Vec::new()); + let gate = Gate::identity(3, noise, tau_g); + let pl = synthesize_exact_unitary(&gate); + + let expected = (delta * tau_g).powi(2) / 4.0; + assert_abs_diff_eq!( + pl.rate(&PauliString::from_label("ZZZ").unwrap()), + expected, + epsilon = 1e-10 + ); + + for ps in PauliString::enumerate_nonidentity(3) { + if format!("{}", ps) == "ZZZ" { + continue; + } + assert_abs_diff_eq!(pl.rate(&ps), 0.0, epsilon = 1e-10); + } +} + +#[test] +#[ignore = "Slow 4Q validation; run explicitly with: cargo test -p pecos-lindblad --test four_qubit_smoke -- --ignored"] +fn four_qubit_identity_ad_on_one_qubit() { + let d = 16; + let i2 = matrix::identity(2); + let sm = matrix::sigma_minus(); + let z = matrix::pauli_1q(Pauli1::Z); + + // AD + PD on qubit 1 only (0-indexed). + let beta_down = 1e-3; + let beta_phi = 2e-3; + let tau_g = 5.0; + + let sm_q1 = kron_all(&[&i2, &sm, &i2, &i2]); + let z_q1 = kron_all(&[&i2, &z, &i2, &i2]); + + let collapse: Vec<(Matrix, f64)> = vec![(sm_q1, beta_down), (z_q1, beta_phi / 2.0)]; + let hamiltonian: Matrix = vec![Complex64::new(0.0, 0.0); d * d]; + let noise = Lindbladian::new(d, hamiltonian, collapse); + + let gate = Gate::identity(4, noise, tau_g); + let pl = synthesize_numerical(&gate, DEFAULT_N_STEPS); + let pl_coarse = synthesize_numerical(&gate, 2); + + // Expected non-zero rates: lambda_{q1=X}, lambda_{q1=Y}, lambda_{q1=Z} + // on qubit 1 (index 1 from left in "qqqq" string). + // lambda_IXII = lambda_IYII = beta_down * tau_g / 4 + // lambda_IZII = beta_phi * tau_g / 2 + let rate = |s: &str| pl.rate(&PauliString::from_label(s).unwrap()); + assert_abs_diff_eq!(rate("IXII"), beta_down * tau_g / 4.0, epsilon = 1e-10); + assert_abs_diff_eq!(rate("IYII"), beta_down * tau_g / 4.0, epsilon = 1e-10); + assert_abs_diff_eq!(rate("IZII"), beta_phi * tau_g / 2.0, epsilon = 1e-10); + for ps in PauliString::enumerate_nonidentity(4) { + assert_abs_diff_eq!(pl.rate(&ps), pl_coarse.rate(&ps), epsilon = 1e-14); + } + + // All other 252 non-identity 4Q Paulis should be zero. + for ps in PauliString::enumerate_nonidentity(4) { + let label = format!("{}", ps); + if label == "IXII" || label == "IYII" || label == "IZII" { + continue; + } + assert_abs_diff_eq!(pl.rate(&ps), 0.0, epsilon = 1e-10); + } +} + +#[test] +#[ignore = "Slow 4Q validation; run explicitly with: cargo test -p pecos-lindblad --test four_qubit_smoke -- --ignored"] +fn four_qubit_identity_coherent_zzzz_smoke() { + // 4Q identity with coherent ZZZZ noise -- since all Zs commute, each + // lambda_{all-Z} should be non-zero, everything else zero. + let d = 16; + let tau_g = 5.0; + let delta = 1e-4; + + let z = matrix::pauli_1q(Pauli1::Z); + let zzzz = kron_all(&[&z, &z, &z, &z]); + let h_delta = matrix::scale(&zzzz, Complex64::new(delta / 2.0, 0.0)); + let noise = Lindbladian::new(d, h_delta, Vec::new()); + let gate = Gate::identity(4, noise, tau_g); + let pl = synthesize_exact_unitary(&gate); + + // lambda_ZZZZ = (delta * tau_g)^2 / 4 (by analogy with 1Q phase noise). + let expected = (delta * tau_g).powi(2) / 4.0; + assert_abs_diff_eq!( + pl.rate(&PauliString::from_label("ZZZZ").unwrap()), + expected, + epsilon = 1e-10 + ); + + // All other 254 non-identity 4Q Paulis zero. + for ps in PauliString::enumerate_nonidentity(4) { + if format!("{}", ps) == "ZZZZ" { + continue; + } + assert_abs_diff_eq!(pl.rate(&ps), 0.0, epsilon = 1e-10); + } +} diff --git a/exp/pecos-lindblad/tests/identity_1q.rs b/exp/pecos-lindblad/tests/identity_1q.rs new file mode 100644 index 000000000..687e19aa8 --- /dev/null +++ b/exp/pecos-lindblad/tests/identity_1q.rs @@ -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. + +//! Golden-fixture test for 1-qubit identity gate under amplitude damping +//! plus pure dephasing (arXiv:2502.03462 line 812, exact non-perturbative): +//! +//! lambda_x = lambda_y = (beta_down * tau_g) / 4 +//! lambda_z = (beta_phi * tau_g) / 2 + +use approx::assert_abs_diff_eq; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::{Gate, Lindbladian, Pauli1, PauliString, synthesize_identity_1q}; + +fn amplitude_damping_plus_dephasing(beta_down: f64, beta_phi: f64) -> Lindbladian { + let d = 2; + let hamiltonian = matrix::zeros(d); + let collapse: Vec<(Matrix, f64)> = vec![ + (matrix::sigma_minus(), beta_down), + (matrix::pauli_1q(Pauli1::Z), beta_phi / 2.0), + ]; + Lindbladian::new(d, hamiltonian, collapse) +} + +#[test] +fn identity_ad_plus_pd_matches_paper() { + let beta_down = 2e-4; + let beta_phi = 5e-4; + let tau_g = 50.0; + let noise = amplitude_damping_plus_dephasing(beta_down, beta_phi); + let gate = Gate::identity(1, noise, tau_g); + + let pl = synthesize_identity_1q(&gate); + + let expected_x = beta_down * tau_g / 4.0; + let expected_y = beta_down * tau_g / 4.0; + let expected_z = beta_phi * tau_g / 2.0; + + assert_abs_diff_eq!( + pl.rate(&PauliString::single(Pauli1::X)), + expected_x, + epsilon = 1e-12 + ); + assert_abs_diff_eq!( + pl.rate(&PauliString::single(Pauli1::Y)), + expected_y, + epsilon = 1e-12 + ); + assert_abs_diff_eq!( + pl.rate(&PauliString::single(Pauli1::Z)), + expected_z, + epsilon = 1e-12 + ); +} + +#[test] +fn identity_ad_only() { + let beta_down = 1e-3; + let tau_g = 100.0; + let noise = amplitude_damping_plus_dephasing(beta_down, 0.0); + let gate = Gate::identity(1, noise, tau_g); + + let pl = synthesize_identity_1q(&gate); + + let expected_xy = beta_down * tau_g / 4.0; + assert_abs_diff_eq!( + pl.rate(&PauliString::single(Pauli1::X)), + expected_xy, + epsilon = 1e-12 + ); + assert_abs_diff_eq!( + pl.rate(&PauliString::single(Pauli1::Y)), + expected_xy, + epsilon = 1e-12 + ); + assert_abs_diff_eq!( + pl.rate(&PauliString::single(Pauli1::Z)), + 0.0, + epsilon = 1e-12 + ); +} + +#[test] +fn identity_pd_only() { + let beta_phi = 7e-4; + let tau_g = 40.0; + let noise = amplitude_damping_plus_dephasing(0.0, beta_phi); + let gate = Gate::identity(1, noise, tau_g); + + let pl = synthesize_identity_1q(&gate); + + let expected_z = beta_phi * tau_g / 2.0; + assert_abs_diff_eq!( + pl.rate(&PauliString::single(Pauli1::X)), + 0.0, + epsilon = 1e-12 + ); + assert_abs_diff_eq!( + pl.rate(&PauliString::single(Pauli1::Y)), + 0.0, + epsilon = 1e-12 + ); + assert_abs_diff_eq!( + pl.rate(&PauliString::single(Pauli1::Z)), + expected_z, + epsilon = 1e-12 + ); +} + +#[test] +fn identity_zero_noise_gives_zero_rates() { + let noise = amplitude_damping_plus_dephasing(0.0, 0.0); + let gate = Gate::identity(1, noise, 1.0); + + let pl = synthesize_identity_1q(&gate); + + for p in [Pauli1::X, Pauli1::Y, Pauli1::Z] { + assert_abs_diff_eq!(pl.rate(&PauliString::single(p)), 0.0, epsilon = 1e-14); + } +} diff --git a/exp/pecos-lindblad/tests/input_validation.rs b/exp/pecos-lindblad/tests/input_validation.rs new file mode 100644 index 000000000..8282a5572 --- /dev/null +++ b/exp/pecos-lindblad/tests/input_validation.rs @@ -0,0 +1,82 @@ +// 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. + +//! Input-validation tests. Bad inputs must produce immediate panics at +//! construction time rather than silently returning wrong results. + +use num_complex::Complex64; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::{Gate, Lindbladian}; + +#[test] +#[should_panic(expected = "Hermitian")] +fn non_hermitian_hamiltonian_in_lindbladian_panics() { + let d = 2; + let mut h: Matrix = vec![Complex64::new(0.0, 0.0); d * d]; + // Asymmetric imaginary entry makes this non-Hermitian. + h[1] = Complex64::new(1.0, 0.0); + h[2] = Complex64::new(2.0, 0.0); // Should be 1.0 for Hermitian. + let _ = Lindbladian::new(d, h, Vec::new()); +} + +#[test] +#[should_panic(expected = "Hermitian")] +fn non_hermitian_ideal_hamiltonian_in_gate_panics() { + let d = 2; + let mut h: Matrix = vec![Complex64::new(0.0, 0.0); d * d]; + h[1] = Complex64::new(0.0, 1.0); // pure imaginary, not Hermitian paired with h[2]=0 + let noise = Lindbladian::zero(d); + let _ = Gate::from_hamiltonian("bad", 1, h, noise, 1.0); +} + +#[test] +#[should_panic(expected = "non-negative")] +fn negative_collapse_rate_panics() { + let d = 2; + let _ = Lindbladian::new(d, matrix::zeros(d), vec![(matrix::sigma_minus(), -1e-3)]); +} + +#[test] +#[should_panic(expected = "tau_g")] +fn negative_tau_g_panics() { + let h = matrix::zeros(2); + let noise = Lindbladian::zero(2); + let _ = Gate::from_hamiltonian("bad", 1, h, noise, -1.0); +} + +#[test] +#[should_panic(expected = "wrong shape")] +fn wrong_matrix_size_panics() { + // 3-element matrix is neither 1x1 nor any d*d. + let h: Matrix = vec![Complex64::new(0.0, 0.0); 3]; + let _ = Lindbladian::new(2, h, Vec::new()); +} + +#[test] +fn hermitian_traceless_is_accepted() { + // Pauli X is Hermitian, traceless. + let x = matrix::pauli_1q(pecos_lindblad::Pauli1::X); + let _ = Lindbladian::new(2, x, Vec::new()); +} + +#[test] +fn hermitian_with_real_diagonal_is_accepted() { + // diag(1, -1, 0.5, -0.5) is Hermitian. + let d = 4; + let mut h: Matrix = vec![Complex64::new(0.0, 0.0); d * d]; + h[0] = Complex64::new(1.0, 0.0); + h[d + 1] = Complex64::new(-1.0, 0.0); + h[2 * d + 2] = Complex64::new(0.5, 0.0); + h[3 * d + 3] = Complex64::new(-0.5, 0.0); + let _ = Lindbladian::new(d, h, Vec::new()); +} diff --git a/exp/pecos-lindblad/tests/inverse_fit.rs b/exp/pecos-lindblad/tests/inverse_fit.rs new file mode 100644 index 000000000..6193f6456 --- /dev/null +++ b/exp/pecos-lindblad/tests/inverse_fit.rs @@ -0,0 +1,138 @@ +// 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. + +//! Inverse fit / parameter recovery tests. Closes the "validation loop": +//! forward synthesis produces rates; inverse recovery back-solves physical +//! parameters from rates. At the 1Q identity level, the recovery is +//! analytic and must be a bit-exact round-trip of +//! [`noise_models::ad_pd_1q`] -> [`synthesize_identity_1q`]. + +use approx::assert_abs_diff_eq; + +use pecos_lindblad::noise_models::{ad_pd_1q, recover_t1_t2_from_identity_1q}; +use pecos_lindblad::{Gate, Pauli1, PauliLindbladModel, PauliString, synthesize_identity_1q}; + +fn synth_1q(t1: f64, t2: f64, tau_g: f64) -> PauliLindbladModel { + synthesize_identity_1q(&Gate::identity(1, ad_pd_1q(t1, t2), tau_g)) +} + +#[test] +fn round_trip_recovery_1q_ad_pd() { + for (t1, t2, tau) in [ + (100.0, 80.0, 1.0), + (300.0, 200.0, 0.5), + (50.0, 50.0, 2.0), // T_2 = T_1 case + ] { + let pl = synth_1q(t1, t2, tau); + let (t1_rec, t2_rec) = recover_t1_t2_from_identity_1q(&pl, tau).unwrap(); + assert_abs_diff_eq!(t1_rec, t1, epsilon = 1e-10); + assert_abs_diff_eq!(t2_rec, t2, epsilon = 1e-10); + } +} + +#[test] +fn recovery_handles_t2_equals_2_t1_limit() { + // Pure-T1 limit (no dephasing): T_2 = 2 T_1, so lambda_z = 0 exactly. + let t1 = 150.0; + let t2 = 2.0 * t1; + let tau = 1.0; + let pl = synth_1q(t1, t2, tau); + let (t1_rec, t2_rec) = recover_t1_t2_from_identity_1q(&pl, tau).unwrap(); + assert_abs_diff_eq!(t1_rec, t1, epsilon = 1e-10); + assert_abs_diff_eq!(t2_rec, t2, epsilon = 1e-10); +} + +#[test] +fn recovery_returns_none_on_inconsistent_rates() { + // lambda_x=0 => cannot determine T_1. + let pl = PauliLindbladModel::new( + vec![ + PauliString::single(Pauli1::X), + PauliString::single(Pauli1::Z), + ], + vec![0.0, 0.01], + ); + assert!(recover_t1_t2_from_identity_1q(&pl, 1.0).is_none()); +} + +#[test] +fn recovery_returns_none_on_zero_tau() { + let pl = synth_1q(100.0, 80.0, 1.0); + assert!(recover_t1_t2_from_identity_1q(&pl, 0.0).is_none()); +} + +#[test] +fn compose_independent_sums_rates() { + let a = PauliLindbladModel::new( + vec![ + PauliString::from_label("IX").unwrap(), + PauliString::from_label("ZZ").unwrap(), + ], + vec![0.001, 0.005], + ); + let b = PauliLindbladModel::new( + vec![ + PauliString::from_label("IX").unwrap(), + PauliString::from_label("XI").unwrap(), + ], + vec![0.002, 0.003], + ); + let ab = a.compose_independent(&b); + // Expected support: IX (merged), XI, ZZ. Rates: IX=0.003, XI=0.003, ZZ=0.005. + assert_abs_diff_eq!( + ab.rate(&PauliString::from_label("IX").unwrap()), + 0.003, + epsilon = 1e-14 + ); + assert_abs_diff_eq!( + ab.rate(&PauliString::from_label("XI").unwrap()), + 0.003, + epsilon = 1e-14 + ); + assert_abs_diff_eq!( + ab.rate(&PauliString::from_label("ZZ").unwrap()), + 0.005, + epsilon = 1e-14 + ); + assert_eq!(ab.supports.len(), 3); +} + +#[test] +fn compose_commutes() { + let a = PauliLindbladModel::new(vec![PauliString::single(Pauli1::X)], vec![0.01]); + let b = PauliLindbladModel::new(vec![PauliString::single(Pauli1::Z)], vec![0.02]); + let ab = a.compose_independent(&b); + let ba = b.compose_independent(&a); + for p in [Pauli1::X, Pauli1::Y, Pauli1::Z] { + let k = PauliString::single(p); + assert_abs_diff_eq!(ab.rate(&k), ba.rate(&k), epsilon = 1e-14); + } +} + +#[test] +fn round_trip_validation_workflow() { + // End-to-end: predict rates from (T1, T2), then recover T1/T2 from + // those rates, verify the loop closes. + let t1_nominal = 250.0; + let t2_nominal = 180.0; + let tau = 0.5; + + // 1. Forward: physics -> rates. + let predicted = synth_1q(t1_nominal, t2_nominal, tau); + + // 2. Inverse: rates -> physics. + let (t1_back, t2_back) = recover_t1_t2_from_identity_1q(&predicted, tau).unwrap(); + + // 3. Closure. + assert_abs_diff_eq!(t1_back, t1_nominal, epsilon = 1e-10); + assert_abs_diff_eq!(t2_back, t2_nominal, epsilon = 1e-10); +} diff --git a/exp/pecos-lindblad/tests/inverse_fit_2q.rs b/exp/pecos-lindblad/tests/inverse_fit_2q.rs new file mode 100644 index 000000000..7fde76826 --- /dev/null +++ b/exp/pecos-lindblad/tests/inverse_fit_2q.rs @@ -0,0 +1,141 @@ +// 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. + +//! 2-qubit inverse-fit tests for `CZ_theta + AD+PD`. Round-trip: +//! (T_1, T_2)_{l,r} -> synthesize -> PL rates -> recover -> (T_1, T_2)_{l,r}. +//! Must be bit-close on clean (noiseless) synthetic data. + +use approx::assert_abs_diff_eq; + +use pecos_lindblad::noise_models::{ + ad_pd_2q, cz_recovery_residual, recover_ad_pd_2q_from_cz_theta, +}; +use pecos_lindblad::{ + DEFAULT_N_STEPS, Gate, PauliLindbladModel, PauliString, synthesize_numerical, +}; + +fn synth_cz( + t1_l: f64, + t1_r: f64, + t2_l: f64, + t2_r: f64, + omega: f64, + theta: f64, +) -> PauliLindbladModel { + let noise = ad_pd_2q(t1_l, t1_r, t2_l, t2_r); + let gate = Gate::cz_theta(omega, theta, noise); + synthesize_numerical(&gate, DEFAULT_N_STEPS) +} + +#[test] +fn round_trip_2q_asymmetric_params() { + // Four independent parameters; recovery should find all four. + let t1_l = 120.0; + let t1_r = 80.0; + let t2_l = 90.0; + let t2_r = 60.0; + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + + let pl = synth_cz(t1_l, t1_r, t2_l, t2_r, omega, theta); + let rec = recover_ad_pd_2q_from_cz_theta(&pl, omega, theta).unwrap(); + + assert_abs_diff_eq!(rec.t1_l, t1_l, epsilon = 1e-10); + assert_abs_diff_eq!(rec.t1_r, t1_r, epsilon = 1e-10); + assert_abs_diff_eq!(rec.t2_l, t2_l, epsilon = 1e-10); + assert_abs_diff_eq!(rec.t2_r, t2_r, epsilon = 1e-10); +} + +#[test] +fn round_trip_2q_at_pi_over_3() { + let pl = synth_cz(200.0, 200.0, 150.0, 150.0, 1.5, std::f64::consts::FRAC_PI_3); + let rec = recover_ad_pd_2q_from_cz_theta(&pl, 1.5, std::f64::consts::FRAC_PI_3).unwrap(); + assert_abs_diff_eq!(rec.t1_l, 200.0, epsilon = 1e-10); + assert_abs_diff_eq!(rec.t1_r, 200.0, epsilon = 1e-10); + assert_abs_diff_eq!(rec.t2_l, 150.0, epsilon = 1e-10); + assert_abs_diff_eq!(rec.t2_r, 150.0, epsilon = 1e-10); +} + +#[test] +fn recovery_residual_small_for_pure_ad_pd() { + // On clean AD+PD synthesis, the degenerate rate pairs should + // coincide to machine precision -> residual ~ 0. + let pl = synth_cz(100.0, 80.0, 80.0, 60.0, 1.0, std::f64::consts::FRAC_PI_4); + let residual = cz_recovery_residual(&pl); + assert!( + residual < 1e-10, + "residual for clean AD+PD should be near zero: {}", + residual + ); +} + +#[test] +fn recovery_residual_nonzero_under_model_mismatch() { + // Build a model where the degenerate pairs explicitly *differ* -- + // simulating measured rates that don't fit the pure-AD+PD form. + let supports: Vec<_> = ["IX", "IY", "XI", "YI"] + .iter() + .map(|s| PauliString::from_label(s).unwrap()) + .collect(); + // IX, IY differ by 20% (not allowed under pure AD+PD for CZ). + let rates = vec![0.003, 0.0036, 0.002, 0.002]; + let pl = PauliLindbladModel::new(supports, rates); + let residual = cz_recovery_residual(&pl); + assert!( + residual > 1e-4, + "expected residual to flag the mismatch, got {}", + residual + ); +} + +#[test] +fn recovery_returns_none_on_negative_rates() { + let supports = vec![ + PauliString::from_label("IX").unwrap(), + PauliString::from_label("IY").unwrap(), + PauliString::from_label("XI").unwrap(), + PauliString::from_label("YI").unwrap(), + PauliString::from_label("IZ").unwrap(), + PauliString::from_label("ZI").unwrap(), + ]; + // lambda_ix is zero -> recovery can't infer beta_down_r. + let rates = vec![0.0, 0.0, 0.001, 0.001, 0.002, 0.002]; + let pl = PauliLindbladModel::new(supports, rates); + assert!(recover_ad_pd_2q_from_cz_theta(&pl, 1.0, std::f64::consts::FRAC_PI_4).is_none()); +} + +#[test] +fn round_trip_2q_validation_workflow() { + // End-to-end validation story: 4 device params -> 15 PL rates -> + // recover 4 params -> compare. + let t1_l = 250.0; + let t2_l = 180.0; + let t1_r = 300.0; + let t2_r = 220.0; + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + + // 1. Forward: physics -> rates. + let pl = synth_cz(t1_l, t1_r, t2_l, t2_r, omega, theta); + + // 2. Consistency check: on clean data the 2-fold pair residual = 0. + assert!(cz_recovery_residual(&pl) < 1e-10); + + // 3. Inverse. + let rec = recover_ad_pd_2q_from_cz_theta(&pl, omega, theta).unwrap(); + + // 4. Closure. + assert_abs_diff_eq!(rec.t1_l, t1_l, epsilon = 1e-10); + assert_abs_diff_eq!(rec.t2_l, t2_l, epsilon = 1e-10); + assert_abs_diff_eq!(rec.t1_r, t1_r, epsilon = 1e-10); + assert_abs_diff_eq!(rec.t2_r, t2_r, epsilon = 1e-10); +} diff --git a/exp/pecos-lindblad/tests/inverse_fit_2q_cx.rs b/exp/pecos-lindblad/tests/inverse_fit_2q_cx.rs new file mode 100644 index 000000000..292d29a6c --- /dev/null +++ b/exp/pecos-lindblad/tests/inverse_fit_2q_cx.rs @@ -0,0 +1,110 @@ +// 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. + +//! 2-qubit inverse-fit tests for `CX_theta + AD+PD`. Trickier than the CZ +//! case because `beta_down_r` and `beta_phi_r` mix in `lambda_iz` / +//! `lambda_zz`. We use the `lambda_iz - lambda_zz` identity to decouple. + +use approx::assert_abs_diff_eq; + +use pecos_lindblad::noise_models::{ad_pd_2q, recover_ad_pd_2q_from_cx_theta}; +use pecos_lindblad::{DEFAULT_N_STEPS, Gate, PauliLindbladModel, synthesize_numerical}; + +fn synth_cx( + t1_l: f64, + t1_r: f64, + t2_l: f64, + t2_r: f64, + omega: f64, + theta: f64, +) -> PauliLindbladModel { + let noise = ad_pd_2q(t1_l, t1_r, t2_l, t2_r); + let gate = Gate::cx_theta(omega, theta, noise); + synthesize_numerical(&gate, DEFAULT_N_STEPS) +} + +#[test] +fn round_trip_cx_pi_over_4() { + let t1_l = 150.0; + let t1_r = 100.0; + let t2_l = 100.0; + let t2_r = 70.0; + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + + let pl = synth_cx(t1_l, t1_r, t2_l, t2_r, omega, theta); + let rec = recover_ad_pd_2q_from_cx_theta(&pl, omega, theta).unwrap(); + // At beta/omega ~ 1e-2, next-order corrections ~ 1e-4 * 1e-2 = 1e-6. + // Use 1e-5 tolerance. + assert_abs_diff_eq!(rec.t1_l, t1_l, epsilon = 1e-5); + assert_abs_diff_eq!(rec.t1_r, t1_r, epsilon = 1e-5); + assert_abs_diff_eq!(rec.t2_l, t2_l, epsilon = 1e-5); + assert_abs_diff_eq!(rec.t2_r, t2_r, epsilon = 1e-5); +} + +#[test] +fn round_trip_cx_pi_over_3() { + let pl = synth_cx(200.0, 300.0, 150.0, 200.0, 1.5, std::f64::consts::FRAC_PI_3); + let rec = recover_ad_pd_2q_from_cx_theta(&pl, 1.5, std::f64::consts::FRAC_PI_3).unwrap(); + assert_abs_diff_eq!(rec.t1_l, 200.0, epsilon = 1e-5); + assert_abs_diff_eq!(rec.t1_r, 300.0, epsilon = 1e-5); + assert_abs_diff_eq!(rec.t2_l, 150.0, epsilon = 1e-5); + assert_abs_diff_eq!(rec.t2_r, 200.0, epsilon = 1e-5); +} + +#[test] +fn recovery_returns_none_at_degenerate_angle_pi_over_2() { + // At theta = pi/2, sin(2 theta) = 0 -> beta_down_r and beta_phi_r + // are not independently recoverable. + let pl = synth_cx(100.0, 100.0, 80.0, 80.0, 1.0, std::f64::consts::FRAC_PI_2); + assert!(recover_ad_pd_2q_from_cx_theta(&pl, 1.0, std::f64::consts::FRAC_PI_2).is_none()); +} + +#[test] +fn recovery_returns_none_at_zero_theta() { + // At theta = 0, the gate is trivial and rates vanish. Also degenerate. + let pl = synth_cx(100.0, 100.0, 80.0, 80.0, 1.0, 0.0); + assert!(recover_ad_pd_2q_from_cx_theta(&pl, 1.0, 0.0).is_none()); +} + +#[test] +fn cx_round_trip_end_to_end() { + // Device-style story: dev data (T1, T2) per qubit -> rates -> + // recover -> compare. + let t1_l = 300.0; + let t2_l = 200.0; + let t1_r = 280.0; + let t2_r = 190.0; + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + + let pl = synth_cx(t1_l, t1_r, t2_l, t2_r, omega, theta); + let rec = recover_ad_pd_2q_from_cx_theta(&pl, omega, theta).unwrap(); + + // Print-style check: all 4 params recovered with ~5 decimal digits accuracy. + for (got, want, name) in [ + (rec.t1_l, t1_l, "T1_l"), + (rec.t2_l, t2_l, "T2_l"), + (rec.t1_r, t1_r, "T1_r"), + (rec.t2_r, t2_r, "T2_r"), + ] { + let rel_err = (got - want).abs() / want; + assert!( + rel_err < 1e-4, + "{}: recovered {} vs true {} (rel_err={})", + name, + got, + want, + rel_err, + ); + } +} diff --git a/exp/pecos-lindblad/tests/izz_crosstalk_3q.rs b/exp/pecos-lindblad/tests/izz_crosstalk_3q.rs new file mode 100644 index 000000000..51e3ae982 --- /dev/null +++ b/exp/pecos-lindblad/tests/izz_crosstalk_3q.rs @@ -0,0 +1,106 @@ +// 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. + +//! Parity test: 3-qubit `CX_theta ⊗ I` gate with coherent IZZ crosstalk +//! between target (q1) and spectator (q2), vs closed-form results from +//! arXiv:2502.03462 eqs. 1009-1011 (`SubApp:3QXtalk`). +//! +//! String index convention: leftmost factor = qubit 0 (control). +//! "IYZ" = I on q0, Y on q1, Z on q2. +//! +//! Paper closed forms (quadratic in delta, weight-3 rates): +//! lambda_iyz = lambda_zyz = sin^4(theta) / 16 * (delta / omega)^2 +//! lambda_izz = [2 theta + sin 2 theta]^2 / 64 * (delta / omega)^2 +//! lambda_zzz = [2 theta - sin 2 theta]^2 / 64 * (delta / omega)^2 +//! +//! All other non-identity 3Q Paulis (there are 63 - 4 = 59 of them) are +//! **zero** to leading order in delta/omega. +//! +//! **Weight-3 rates** break the standard sparse-PL weight-2 assumption -- +//! this test also exercises `PauliLindbladModel::supports` over the full +//! 3Q basis. + +use approx::assert_abs_diff_eq; + +use pecos_lindblad::{Gate, PauliString, synthesize_exact_unitary}; + +fn paper_rate(label: &str, theta: f64, omega: f64, delta: f64) -> f64 { + let dratio_sq = (delta / omega).powi(2); + let s = theta.sin(); + let s2 = (2.0 * theta).sin(); + match label { + "IYZ" | "ZYZ" => s.powi(4) / 16.0 * dratio_sq, + "IZZ" => (2.0 * theta + s2).powi(2) / 64.0 * dratio_sq, + "ZZZ" => (2.0 * theta - s2).powi(2) / 64.0 * dratio_sq, + _ => 0.0, + } +} + +fn run_izz(theta: f64, omega: f64, delta: f64, tol: f64) { + let gate = Gate::cx_theta_with_izz_crosstalk(omega, theta, delta); + let pl = synthesize_exact_unitary(&gate); + + // All 63 non-identity 3Q Paulis. + for ps in PauliString::enumerate_nonidentity(3) { + let label = format!("{}", ps); + let got = pl.rate(&ps); + let expected = paper_rate(&label, theta, omega, delta); + assert_abs_diff_eq!(got, expected, epsilon = tol); + } +} + +#[test] +fn izz_crosstalk_pi_over_4_weak() { + // delta/omega = 1e-3 => rates ~ 1e-6 at most; tol ~1e-10. + run_izz(std::f64::consts::FRAC_PI_4, 1.0, 1e-3, 1e-10); +} + +#[test] +fn izz_crosstalk_pi_over_2_weak() { + // theta = pi/2 (Clifford): sin(2 theta) = 0, so lambda_izz = lambda_zzz. + run_izz(std::f64::consts::FRAC_PI_2, 1.0, 1e-3, 1e-10); +} + +#[test] +fn izz_crosstalk_pi_over_3_weak() { + run_izz(std::f64::consts::FRAC_PI_3, 1.5, 5e-4, 1e-10); +} + +#[test] +fn izz_crosstalk_zero_delta_gives_zero_rates() { + // delta = 0 => no crosstalk => all rates zero. + let gate = Gate::cx_theta_with_izz_crosstalk(1.0, std::f64::consts::FRAC_PI_4, 0.0); + let pl = synthesize_exact_unitary(&gate); + for ps in PauliString::enumerate_nonidentity(3) { + assert_abs_diff_eq!(pl.rate(&ps), 0.0, epsilon = 1e-14); + } +} + +#[test] +fn izz_crosstalk_produces_only_weight_3_and_no_weight_2() { + // The paper's claim: this gate produces weight-2 (IZZ) AND weight-3 + // (IYZ, ZYZ, ZZZ) rates but NO weight-1 rates. Verify the weight + // distribution in our output matches that claim. + let gate = Gate::cx_theta_with_izz_crosstalk(1.0, std::f64::consts::FRAC_PI_4, 1e-3); + let pl = synthesize_exact_unitary(&gate); + + // Weight-1 rates should all be (numerically) zero. + for ps in PauliString::enumerate_nonidentity(3) { + if ps.weight() == 1 { + assert_abs_diff_eq!(pl.rate(&ps), 0.0, epsilon = 1e-10); + } + } + + // At least one weight-3 rate (e.g. lambda_iyz) should be non-zero. + let iyz = PauliString::from_label("IYZ").unwrap(); + assert!(pl.rate(&iyz) > 1e-12); +} diff --git a/exp/pecos-lindblad/tests/noise_budget.rs b/exp/pecos-lindblad/tests/noise_budget.rs new file mode 100644 index 000000000..26f1ac1e7 --- /dev/null +++ b/exp/pecos-lindblad/tests/noise_budget.rs @@ -0,0 +1,93 @@ +// 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. + +//! Tests for `PauliLindbladModel::top_contributors` / `explain`. + +use approx::assert_abs_diff_eq; + +use pecos_lindblad::{PauliLindbladModel, PauliString}; + +fn model(entries: &[(&str, f64)]) -> PauliLindbladModel { + let supports: Vec<_> = entries + .iter() + .map(|(s, _)| PauliString::from_label(s).unwrap()) + .collect(); + let rates: Vec<_> = entries.iter().map(|(_, r)| *r).collect(); + PauliLindbladModel::new(supports, rates) +} + +#[test] +fn top_contributors_sorted_by_rate_descending() { + let m = model(&[("IX", 0.001), ("ZI", 0.005), ("IY", 0.003), ("IZ", 0.002)]); + let top = m.top_contributors(4); + assert_eq!(format!("{}", top[0].0), "ZI"); // 0.005 + assert_eq!(format!("{}", top[1].0), "IY"); // 0.003 + assert_eq!(format!("{}", top[2].0), "IZ"); // 0.002 + assert_eq!(format!("{}", top[3].0), "IX"); // 0.001 +} + +#[test] +fn top_contributors_truncates_to_n() { + let m = model(&[("X", 0.01), ("Y", 0.02), ("Z", 0.03)]); + let top2 = m.top_contributors(2); + assert_eq!(top2.len(), 2); + assert_eq!(format!("{}", top2[0].0), "Z"); + assert_eq!(format!("{}", top2[1].0), "Y"); +} + +#[test] +fn top_contributors_ties_broken_lexicographically() { + let m = model(&[("X", 0.01), ("Y", 0.01), ("Z", 0.01)]); + let top = m.top_contributors(3); + // Tie on rate -> lexicographic on Pauli1 (I beta_down = 1e4, beta_phi = 7500. + let t1 = 100e-6; + let t2 = 80e-6; + let tau_g = 1e-6; + let (bd, bp) = t1_t2_to_rates(t1, t2); + + let noise = ad_pd_1q(t1, t2); + let gate = Gate::identity(1, noise, tau_g); + let pl = synthesize_identity_1q(&gate); + + let rate = |p: Pauli1| pl.rate(&PauliString::single(p)); + assert_abs_diff_eq!(rate(Pauli1::X), bd * tau_g / 4.0, epsilon = 1e-14); + assert_abs_diff_eq!(rate(Pauli1::Y), bd * tau_g / 4.0, epsilon = 1e-14); + assert_abs_diff_eq!(rate(Pauli1::Z), bp * tau_g / 2.0, epsilon = 1e-14); +} + +#[test] +fn cx_theta_via_device_params_matches_hand_rolled() { + // Build the same gate both ways and confirm rates agree. + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + let t1_l = 100.0; + let t1_r = 80.0; + let t2_l = 120.0; + let t2_r = 90.0; + + let noise = ad_pd_2q(t1_l, t1_r, t2_l, t2_r); + let gate = Gate::cx_theta(omega, theta, noise); + let pl = synthesize_numerical(&gate, DEFAULT_N_STEPS); + + // Paper eq 941 sanity: lambda_xi = (2 theta + sin 2 theta) / 16 * beta_down_l / omega. + let (bd_l, _) = t1_t2_to_rates(t1_l, t2_l); + let s2 = (2.0 * theta).sin(); + let expected = (2.0 * theta + s2) / 16.0 * bd_l / omega; + assert_abs_diff_eq!( + pl.rate(&PauliString::from_label("XI").unwrap()), + expected, + epsilon = 1e-8 + ); +} + +#[test] +fn coherent_phase_2q_via_api() { + // Check that coherent_phase_2q + synthesize_exact_unitary reproduces + // paper eq 981 for CZ_theta. + let omega_cz = 1.0; + let theta = std::f64::consts::FRAC_PI_3; + let delta_iz = 1e-6; + let delta_zi = 2e-6; + let delta_zz = 5e-7; + + let noise = coherent_phase_2q(delta_iz, delta_zi, delta_zz); + let gate = Gate::cz_theta(omega_cz, theta, noise); + let pl = synthesize_exact_unitary(&gate); + + let rate = |s: &str| pl.rate(&PauliString::from_label(s).unwrap()); + let factor = theta.powi(2) / 4.0 / omega_cz.powi(2); + assert_abs_diff_eq!(rate("IZ"), factor * delta_iz.powi(2), epsilon = 1e-14); + assert_abs_diff_eq!(rate("ZI"), factor * delta_zi.powi(2), epsilon = 1e-14); + assert_abs_diff_eq!(rate("ZZ"), factor * delta_zz.powi(2), epsilon = 1e-14); +} diff --git a/exp/pecos-lindblad/tests/non_markovian.rs b/exp/pecos-lindblad/tests/non_markovian.rs new file mode 100644 index 000000000..1eb60aa13 --- /dev/null +++ b/exp/pecos-lindblad/tests/non_markovian.rs @@ -0,0 +1,208 @@ +// 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. + +//! Non-Markovian dynamics tests. Two case studies: +//! +//! 1. **TCL sanity**: a constant-rate time-dependent Lindbladian must +//! reproduce the Markovian result for identity + PD to machine +//! precision. +//! 2. **1/f-style rate**: `gamma_phi(t) = gamma_0 A / (A + t/t_c)` with +//! `A >> tau_g/t_c` reproduces Markov to leading order; as `tau_g/t_c` +//! grows, rates should DIFFER from Markov prediction, demonstrating +//! we actually capture the non-Markovian correction. + +use std::sync::Arc; + +use approx::assert_abs_diff_eq; +use num_complex::Complex64; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::noise_models::ad_pd_1q; +use pecos_lindblad::{ + DEFAULT_N_SLICES, Gate, Pauli1, PauliString, RateFn, TimeDepLindbladian, + synthesize_identity_1q, synthesize_superop_time_dep, +}; + +/// 1-qubit Z operator for PD. +fn z1q() -> Matrix { + matrix::pauli_1q(Pauli1::Z) +} + +#[test] +fn constant_rate_time_dep_matches_markovian() { + // Build a time-dependent Lindbladian whose rates happen to be constant. + // Synthesis should match the Markovian synthesize_identity_1q to + // high precision. + let beta_phi: f64 = 2e-3; + let tau_g: f64 = 1.0; + let d = 2; + let rate_fn: RateFn = Arc::new(move |_t| beta_phi / 2.0); + let noise_td = + TimeDepLindbladian::with_static_hamiltonian(d, matrix::zeros(d), vec![(z1q(), rate_fn)]); + + // For identity gate, T1 = infinity effectively: use just PD. + let h_ideal = matrix::zeros(d); + let pl_td = synthesize_superop_time_dep(1, &h_ideal, &noise_td, tau_g, DEFAULT_N_SLICES); + + // Markovian baseline: identity + PD only (no AD). + // For pure PD at rate beta_phi/2 on Z: paper eq 801 says + // lambda_z = (beta_phi * tau_g) / 2. + // Our T1/T2 API requires beta_down > 0; emulate "AD off" via T2 = 2 T1 + // at huge T1 so beta_down -> 0. + let t1 = 1e18; + let t2 = 1.0 / beta_phi; // 1/T_2 = 1/(2T_1) + beta_phi -> beta_phi + let noise_mk = ad_pd_1q(t1, t2); + let pl_mk = synthesize_identity_1q(&Gate::identity(1, noise_mk, tau_g)); + + for p in [Pauli1::X, Pauli1::Y, Pauli1::Z] { + let k = PauliString::single(p); + assert_abs_diff_eq!(pl_td.rate(&k), pl_mk.rate(&k), epsilon = 1e-9); + } +} + +#[test] +fn one_over_f_weak_non_markov_reduces_to_markov() { + // 1/f-ish rate: gamma_phi(t) = gamma_0 * A / (A + t/t_c) with A=1, t_c + // large compared to tau_g -> rate ~ gamma_0 constant -> should match + // Markov to high precision. + let gamma_0: f64 = 1e-3; + let t_c: f64 = 1e6; // very slow variation + let a: f64 = 1.0; + let tau_g: f64 = 1.0; + let d = 2; + + let rate_fn: RateFn = Arc::new(move |t: f64| gamma_0 * a / (a + t / t_c) / 2.0); + let noise_td = + TimeDepLindbladian::with_static_hamiltonian(d, matrix::zeros(d), vec![(z1q(), rate_fn)]); + let h_ideal = matrix::zeros(d); + let pl_nm = synthesize_superop_time_dep(1, &h_ideal, &noise_td, tau_g, 256); + + // Corresponding Markov (constant rate = gamma_0): lambda_z = gamma_0 * tau_g / 2. + let expected_z = gamma_0 * tau_g / 2.0; + assert_abs_diff_eq!( + pl_nm.rate(&PauliString::single(Pauli1::Z)), + expected_z, + epsilon = 1e-8 + ); +} + +#[test] +fn one_over_f_strong_non_markov_differs_from_markov() { + // Now use t_c comparable to tau_g -> gamma_phi(t) varies substantially + // over the gate. Predicted PL rate must DIFFER from the constant-rate + // Markov prediction, proving the non-Markovian correction is captured. + let gamma_0: f64 = 1e-3; + let a: f64 = 1.0; + let tau_g: f64 = 1.0; + let t_c: f64 = tau_g / 2.0; // rate drops substantially over the gate + let d = 2; + + let rate_fn: RateFn = Arc::new(move |t: f64| gamma_0 * a / (a + t / t_c) / 2.0); + let noise_td = + TimeDepLindbladian::with_static_hamiltonian(d, matrix::zeros(d), vec![(z1q(), rate_fn)]); + let h_ideal = matrix::zeros(d); + let pl_nm = synthesize_superop_time_dep(1, &h_ideal, &noise_td, tau_g, 512); + + // Analytic: integrated rate = int_0^tau_g gamma(t) dt + // = int_0^tau_g gamma_0 / (1 + 2 t/tau_g) dt + // = gamma_0 * (tau_g/2) * ln(1 + 2) + // = gamma_0 * tau_g * ln(3) / 2 + // lambda_z = (integrated rate) / 2 (paper convention: lambda_z = beta_phi * tau_g / 2). + // Wait: with our factor-of-1/2 in rate_fn (beta_phi/2), integrated is + // gamma_0 * tau_g * ln(3) / 2 / 2. Let me work this out again. + // + // Effective PD: D[Z] rho = Z rho Z - rho, rate = gamma_phi(t)/2. + // Integrated rate per paper: lambda_z = int (gamma_phi(t)/2) dt * 2 = int gamma_phi(t) dt. + // Hmm -- actually the paper's identity closed form is lambda_z = beta_phi * tau_g / 2 + // where beta_phi is the RATE coefficient attached to (beta_phi/2) D[Z] + // (i.e. the "1/T_phi"). So: + // lambda_z(const) = beta_phi * tau_g / 2. + // For time-dep: lambda_z(t-dep) = (1/2) * int_0^tau_g gamma_0/(1 + 2t/tau_g) dt + // = (1/2) * gamma_0 * (tau_g/2) * ln(1 + 2) + // = gamma_0 * tau_g * ln(3) / 4. + let expected_nm = gamma_0 * tau_g * (3.0_f64).ln() / 4.0; + let got = pl_nm.rate(&PauliString::single(Pauli1::Z)); + + // Same prediction via constant-rate Markov model: + let lambda_z_mk = gamma_0 * tau_g / 2.0; + + // Markov and non-Markov disagree substantially. + assert!( + (got - lambda_z_mk).abs() > 0.1 * lambda_z_mk, + "non-Markov should differ substantially from Markov: got {}, markov {}", + got, + lambda_z_mk + ); + // And our non-Markov result matches the analytic integrated-rate formula. + assert_abs_diff_eq!(got, expected_nm, epsilon = 1e-7); +} + +#[test] +fn time_dependent_coherent_noise_gaussian_pulse() { + // H_delta(t) = (delta * exp(-((t - tau_g/2)/sigma)^2) / 2) * Z + // (Gaussian envelope of coherent Z over the gate). Verify rates + // scale with the envelope's time integral. + let delta: f64 = 1e-4; + let tau_g: f64 = 1.0; + let sigma: f64 = tau_g / 4.0; + let d = 2; + + let h_fn: pecos_lindblad::time_dep::HermitianFn = Arc::new(move |t: f64| { + let arg = ((t - tau_g / 2.0) / sigma).powi(2); + let amp = delta * (-arg).exp() / 2.0; + matrix::scale(&z1q(), Complex64::new(amp, 0.0)) + }); + let noise_td = TimeDepLindbladian::new(d, h_fn, vec![]); + let h_ideal = matrix::zeros(d); + let pl_nm = synthesize_superop_time_dep(1, &h_ideal, &noise_td, tau_g, 256); + + // For purely coherent Z noise on identity: + // effective unitary phase phi = int_0^tau_g (H(t) component of Z) dt + // = delta * int_0^tau_g exp(-((t-tau/2)/sigma)^2) / 2 dt + // = delta/2 * sigma * sqrt(pi) * erf(tau_g/(2 sigma)) ... + // for sigma = tau_g/4 and tau_g = 1: integral approx = sqrt(pi) * tau_g/4 * erf(2) + // erf(2) ~ 0.9953 + let integral_gaussian = sigma * std::f64::consts::PI.sqrt() * erf(tau_g / (2.0 * sigma)); + let phi = delta * integral_gaussian / 2.0; + // For coherent Z on identity: lambda_z = phi^2 / 2 (from -ln(cos(phi)) ~ phi^2/2). + // Walsh-Hadamard gives lambda_z = alpha_z * tau_g / 4 * 2 = ... actually the + // 1Q identity-coherent-Z result: lambda_z = phi^2 / 2 where phi is total phase. + // + // Actually from our previous phase-noise test: for constant (delta/2) Z, + // lambda_z = (delta * tau_g / 2)^2 / 2 / 2 = phi^2 / 4 where phi = delta * tau_g / 2. + // Hmm let's just check order of magnitude. + let got = pl_nm.rate(&PauliString::single(Pauli1::Z)); + assert!(got > 0.0, "Gaussian pulse should produce nonzero lambda_z"); + // For coherent Z-on-identity, lambda_z ~ phi^2 at leading order. + // Allow factor-of-2 margin either side. + assert!( + got > 0.5 * phi * phi && got < 2.0 * phi * phi, + "got {}, phi^2 {} -- expected leading-order ~phi^2", + got, + phi * phi, + ); +} + +/// Abramowitz-Stegun approximation of erf. Accuracy ~7 digits. +fn erf(x: f64) -> f64 { + let a1 = 0.254829592; + let a2 = -0.284496736; + let a3 = 1.421413741; + let a4 = -1.453152027; + let a5 = 1.061405429; + let p = 0.3275911; + let sign = if x < 0.0 { -1.0 } else { 1.0 }; + let x = x.abs(); + let t = 1.0 / (1.0 + p * x); + let y = 1.0 - (((((a5 * t + a4) * t) + a3) * t + a2) * t + a1) * t * (-x * x).exp(); + sign * y +} diff --git a/exp/pecos-lindblad/tests/per_gate_bridge.rs b/exp/pecos-lindblad/tests/per_gate_bridge.rs new file mode 100644 index 000000000..f6fc4e9be --- /dev/null +++ b/exp/pecos-lindblad/tests/per_gate_bridge.rs @@ -0,0 +1,117 @@ +// 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. + +//! End-to-end bridge: pecos-lindblad synthesis -> PauliLindbladModel +//! adapter arrays -> pecos-qec PerGateTypeNoise -> DemStabSim. +//! +//! This is the **honest integration** the `Phase 5 scaffold` flagged as +//! missing. Closes the loop: device params -> per-gate per-Pauli rates +//! -> DEM mechanisms -> sampling -> shot batches. + +use rand::SeedableRng; +use rand::rngs::SmallRng; + +use pecos_lindblad::noise_models::{ad_pd_1q, ad_pd_2q}; +use pecos_lindblad::{DEFAULT_N_STEPS, Gate, synthesize_identity_1q, synthesize_numerical}; +use pecos_qec::dem_stab::DemStabSim; +use pecos_qec::fault_tolerance::dem_builder::{DetectorDef, NoiseConfig, PerGateTypeNoise}; +use pecos_quantum::{DagCircuit, GateType}; + +#[test] +fn lindblad_rates_flow_through_per_gate_noise_spec() { + // 1Q identity rates (for idle locations) and 2Q CX rates. + let t1 = 100.0; + let t2 = 80.0; + let tau_1q = 0.5; + let tau_cx = std::f64::consts::FRAC_PI_2; + + let pl_1q = synthesize_identity_1q(&Gate::identity(1, ad_pd_1q(t1, t2), tau_1q)); + let pl_cx = synthesize_numerical( + &Gate::cx_theta(1.0, tau_cx, ad_pd_2q(t1, t1, t2, t2)), + DEFAULT_N_STEPS, + ); + + // Bundle into a PerGateTypeNoise. The base noise model gives uncovered gate + // types (e.g. PZ prep, MZ measure) a small uniform rate. + let cfg = PerGateTypeNoise::from_base_noise(NoiseConfig::new(0.0, 0.0, 1e-3, 1e-3)) + .with_1q_rates(GateType::H, pl_1q.to_noise_array_1q()) + .with_2q_rates(GateType::CX, pl_cx.to_noise_array_2q()); + + // Small syndrome-extraction circuit. + let mut dag = DagCircuit::new(); + dag.pz(&[2]); + dag.cx(&[(0, 2)]); + dag.cx(&[(1, 2)]); + dag.mz(&[2]); + + let sim = DemStabSim::builder() + .circuit(dag) + .per_gate_noise(cfg) // overrides .noise() when both set + .detectors(vec![DetectorDef::new(0).with_records([-1])]) + .build() + .expect("DemStabSim build"); + + // Sample shots; sanity-check detector flips length matches detector count. + let mut rng = SmallRng::seed_from_u64(42); + let batch = sim.sample_batch(500, &mut rng); + assert_eq!(batch.detector_flips.len(), 500); + assert_eq!(batch.detector_flips[0].len(), 1); +} + +#[test] +fn to_noise_array_1q_round_trip() { + let pl = synthesize_identity_1q(&Gate::identity(1, ad_pd_1q(100.0, 80.0), 1.0)); + let arr = pl.to_noise_array_1q(); + // Paper: lambda_x = lambda_y, lambda_z different. + assert!((arr[0] - arr[1]).abs() < 1e-14); + assert!(arr[2] > 0.0); + // Sum of rates matches total_rate. + assert!((arr.iter().sum::() - pl.total_rate()).abs() < 1e-14); +} + +#[test] +fn to_noise_array_2q_preserves_paper_structure() { + // CX + AD+PD produces specific non-zero rates per paper eq 929-956. + // Check the array matches the rate lookups. + let pl = synthesize_numerical( + &Gate::cx_theta( + 1.0, + std::f64::consts::FRAC_PI_4, + ad_pd_2q(100.0, 80.0, 100.0, 80.0), + ), + DEFAULT_N_STEPS, + ); + let arr = pl.to_noise_array_2q(); + // Sum across the array matches total rate. + assert!((arr.iter().sum::() - pl.total_rate()).abs() < 1e-14); + // Several entries must be non-zero (IX, XI, IZ, ZI, ZZ and more). + let non_zero = arr.iter().filter(|r| r.abs() > 1e-12).count(); + assert!( + non_zero >= 6, + "expected at least 6 non-zero rates in CX+AD+PD array, got {}", + non_zero + ); +} + +#[test] +#[should_panic(expected = "1-qubit")] +fn to_noise_array_1q_panics_on_2q_model() { + let pl = synthesize_numerical( + &Gate::cx_theta( + 1.0, + std::f64::consts::FRAC_PI_4, + ad_pd_2q(100.0, 100.0, 80.0, 80.0), + ), + DEFAULT_N_STEPS, + ); + let _ = pl.to_noise_array_1q(); +} diff --git a/exp/pecos-lindblad/tests/phase_noise_2q.rs b/exp/pecos-lindblad/tests/phase_noise_2q.rs new file mode 100644 index 000000000..9319adc87 --- /dev/null +++ b/exp/pecos-lindblad/tests/phase_noise_2q.rs @@ -0,0 +1,182 @@ +// 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. + +//! Parity tests: 2-qubit gates under coherent phase noise from +//! arXiv:2502.03462 SubApp:2QPhNoise (lines 962-1001). +//! +//! Noise Hamiltonian: +//! H_delta = (delta_iz/2) IZ + (delta_zi/2) ZI + (delta_zz/2) ZZ +//! +//! Cases tested: +//! - (i) Identity (H_g = 0): all three delta components commute with H_g, +//! so rates are quadratic-in-delta and decoupled (eq. 981). +//! - (iii) CX_theta: phase noise doesn't commute with X_target, producing +//! mixing between delta_iz and delta_zz into lambda_iy, lambda_zy, +//! lambda_iz, lambda_zz (eqs. 986-990). +//! +//! Synthesis path: `synthesize_exact_unitary` (coherent noise, no c_ops). + +use approx::assert_abs_diff_eq; +use num_complex::Complex64; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::{Gate, Lindbladian, Pauli1, PauliString, synthesize_exact_unitary}; + +fn phase_noise_2q(delta_iz: f64, delta_zi: f64, delta_zz: f64) -> Lindbladian { + let d = 4; + let i2 = matrix::identity(2); + let z = matrix::pauli_1q(Pauli1::Z); + let iz = matrix::kron(&i2, &z, 2, 2); + let zi = matrix::kron(&z, &i2, 2, 2); + let zz = matrix::kron(&z, &z, 2, 2); + let half = Complex64::new(0.5, 0.0); + let h_delta: Matrix = matrix::add( + &matrix::add( + &matrix::scale(&iz, Complex64::new(delta_iz, 0.0) * half), + &matrix::scale(&zi, Complex64::new(delta_zi, 0.0) * half), + ), + &matrix::scale(&zz, Complex64::new(delta_zz, 0.0) * half), + ); + Lindbladian::new(d, h_delta, Vec::new()) +} + +#[test] +fn identity_2q_phase_noise_commuting_case() { + // Paper eq. 981: lambda_iz = (tau_g * delta_iz)^2 / 4, etc. + // (obtained by setting theta_cz = omega_cz * tau_g and dividing). + // Use weak noise so O(g^4) corrections stay below the 1e-10 tolerance. + // At g = delta * tau ~ 1e-5 the next-order correction ~g^4/24 ~ 4e-22. + let tau_g = 10.0; + let delta_iz = 1e-6; + let delta_zi = 2e-6; + let delta_zz = 5e-7; + let noise = phase_noise_2q(delta_iz, delta_zi, delta_zz); + let gate = Gate::identity(2, noise, tau_g); + let pl = synthesize_exact_unitary(&gate); + + let rate = |s: &str| pl.rate(&PauliString::from_label(s).unwrap()); + assert_abs_diff_eq!( + rate("IZ"), + (delta_iz * tau_g).powi(2) / 4.0, + epsilon = 1e-10 + ); + assert_abs_diff_eq!( + rate("ZI"), + (delta_zi * tau_g).powi(2) / 4.0, + epsilon = 1e-10 + ); + assert_abs_diff_eq!( + rate("ZZ"), + (delta_zz * tau_g).powi(2) / 4.0, + epsilon = 1e-10 + ); + + // All others should be zero (phase noise commutes, so only Z-basis + // rates appear). + for label in [ + "IX", "IY", "XI", "XX", "XY", "XZ", "YI", "YX", "YY", "YZ", "ZX", "ZY", + ] { + assert_abs_diff_eq!(rate(label), 0.0, epsilon = 1e-10); + } +} + +#[test] +fn cx_theta_phase_noise_mixing_case() { + // Paper eqs. 986-990: CX_theta with H_delta = IZ, ZI, ZZ phase noise. + // lambda_zi = theta^2 / 4 * (delta_zi / omega)^2 + // lambda_iy = lambda_zy = sin^4(theta) / 16 * ((delta_iz - delta_zz) / omega)^2 + // lambda_iz = [2 theta (delta_iz + delta_zz) + // + sin(2 theta)(delta_iz - delta_zz)]^2 / (64 omega^2) + // lambda_zz = [2 theta (delta_iz + delta_zz) + // + sin(2 theta)(delta_zz - delta_iz)]^2 / (64 omega^2) + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + let delta_iz = 1e-3; + let delta_zi = 2e-3; + let delta_zz = 5e-4; + + let noise = phase_noise_2q(delta_iz, delta_zi, delta_zz); + let gate = Gate::cx_theta(omega, theta, noise); + let pl = synthesize_exact_unitary(&gate); + + let rate = |s: &str| pl.rate(&PauliString::from_label(s).unwrap()); + + let s2t = (2.0 * theta).sin(); + let sin4 = theta.sin().powi(4); + let sum = delta_iz + delta_zz; + let diff_iz_zz = delta_iz - delta_zz; + + let expected_zi = theta.powi(2) / 4.0 * (delta_zi / omega).powi(2); + let expected_iy_zy = sin4 / 16.0 * (diff_iz_zz / omega).powi(2); + let expected_iz = (2.0 * theta * sum + s2t * diff_iz_zz).powi(2) / (64.0 * omega.powi(2)); + let expected_zz = (2.0 * theta * sum + s2t * (-diff_iz_zz)).powi(2) / (64.0 * omega.powi(2)); + + assert_abs_diff_eq!(rate("ZI"), expected_zi, epsilon = 1e-9); + assert_abs_diff_eq!(rate("IY"), expected_iy_zy, epsilon = 1e-9); + assert_abs_diff_eq!(rate("ZY"), expected_iy_zy, epsilon = 1e-9); + assert_abs_diff_eq!(rate("IZ"), expected_iz, epsilon = 1e-9); + assert_abs_diff_eq!(rate("ZZ"), expected_zz, epsilon = 1e-9); + + // Other 10 rates should be zero to leading order. + for label in ["IX", "XI", "XX", "XY", "XZ", "YI", "YX", "YY", "YZ", "ZX"] { + assert_abs_diff_eq!(rate(label), 0.0, epsilon = 1e-9); + } +} + +#[test] +fn cz_theta_phase_noise_commuting_case() { + // Paper eq. 981 case (ii): CZ_theta with phase noise. + // Since H_g = (omega_cz/2)(II-IZ-ZI+ZZ) is diagonal and phase noise is + // also diagonal, the Hamiltonians commute. Leading-order rates: + // lambda_iz = theta_cz^2 / 4 * (delta_iz / omega_cz)^2 + // lambda_zi = same with delta_zi + // lambda_zz = same with delta_zz + let omega_cz = 1.0; + let theta = std::f64::consts::FRAC_PI_3; + let delta_iz = 1e-6; + let delta_zi = 2e-6; + let delta_zz = 5e-7; + let noise = phase_noise_2q(delta_iz, delta_zi, delta_zz); + let gate = Gate::cz_theta(omega_cz, theta, noise); + let pl = synthesize_exact_unitary(&gate); + + let rate = |s: &str| pl.rate(&PauliString::from_label(s).unwrap()); + let factor = theta.powi(2) / 4.0 / omega_cz.powi(2); + assert_abs_diff_eq!(rate("IZ"), factor * delta_iz.powi(2), epsilon = 1e-14); + assert_abs_diff_eq!(rate("ZI"), factor * delta_zi.powi(2), epsilon = 1e-14); + assert_abs_diff_eq!(rate("ZZ"), factor * delta_zz.powi(2), epsilon = 1e-14); + + // All non-Z-basis rates should be zero (commuting case, no mixing). + for label in [ + "IX", "IY", "XI", "XX", "XY", "XZ", "YI", "YX", "YY", "YZ", "ZX", "ZY", + ] { + assert_abs_diff_eq!(rate(label), 0.0, epsilon = 1e-14); + } +} + +#[test] +fn cx_theta_phase_noise_pi_over_2() { + // theta = pi/2 => sin(2 theta) = 0; the mixing term vanishes and + // lambda_iz = lambda_zz. + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_2; + let delta_iz = 1e-3; + let delta_zz = 2e-3; + let noise = phase_noise_2q(delta_iz, 0.0, delta_zz); + let gate = Gate::cx_theta(omega, theta, noise); + let pl = synthesize_exact_unitary(&gate); + + let rate = |s: &str| pl.rate(&PauliString::from_label(s).unwrap()); + let expected = (2.0 * theta * (delta_iz + delta_zz)).powi(2) / (64.0 * omega.powi(2)); + assert_abs_diff_eq!(rate("IZ"), expected, epsilon = 1e-9); + assert_abs_diff_eq!(rate("ZZ"), expected, epsilon = 1e-9); +} diff --git a/exp/pecos-lindblad/tests/positivity_sweep.rs b/exp/pecos-lindblad/tests/positivity_sweep.rs new file mode 100644 index 000000000..6d2fab378 --- /dev/null +++ b/exp/pecos-lindblad/tests/positivity_sweep.rs @@ -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. + +//! Positivity stress test: scan noise strength `beta/omega` across a range +//! and verify `synthesize_numerical` never panics and produces only +//! non-negative rates. Documents the regime where the first-order PL model +//! stays self-consistent (leading order of Magnus assumes weak coupling). + +use pecos_lindblad::noise_models::ad_pd_2q; +use pecos_lindblad::{DEFAULT_N_STEPS, Gate, PauliString, synthesize_numerical}; + +fn max_negative_rate(rates: &[f64]) -> f64 { + rates + .iter() + .copied() + .filter(|&r| r < 0.0) + .map(|r| -r) + .fold(0.0, f64::max) +} + +#[test] +fn cx_theta_rates_non_negative_across_weak_regime() { + // beta/omega from 1e-5 to 1e-1 (a factor of 10^4 sweep). This brackets + // realistic device regimes: T1/tau_g >> 1 on IBM-like hardware maps to + // beta/omega ~ 1e-4 or smaller. + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + let tau_g = theta / omega; + + // Run the sweep by holding T2 = 2*T1 (pure-dephasing-free) and varying T1. + // Ratio beta_down * tau_g tracks inverse T1. Values: 0.1/tau_g ... 1e-5/tau_g. + let ratios = [1e-5, 1e-4, 1e-3, 1e-2, 5e-2, 1e-1]; + for &r in &ratios { + let t1 = tau_g / r; + let t2 = 2.0 * t1; + let noise = ad_pd_2q(t1, t1, t2, t2); + let gate = Gate::cx_theta(omega, theta, noise); + let pl = synthesize_numerical(&gate, DEFAULT_N_STEPS); + let rates: Vec = PauliString::enumerate_nonidentity(2) + .iter() + .map(|p| pl.rate(p)) + .collect(); + assert!( + max_negative_rate(&rates) < 1e-12, + "negative rate at beta/omega={}: max negative = {}", + r, + max_negative_rate(&rates), + ); + // Sanity: highest rate should scale linearly with r to leading order. + let total: f64 = rates.iter().sum(); + assert!( + total > 0.0 && total < 2.0, + "total rate out of range at beta/omega={}: total={}", + r, + total, + ); + } +} + +#[test] +fn identity_2q_rates_non_negative_across_weak_regime() { + // Same sweep on identity + AD+PD (Phase 1 exact-fixture regime). + let tau_g = 10.0; + let ratios = [1e-6, 1e-4, 1e-2, 1e-1]; + for &r in &ratios { + let t1 = tau_g / r; + let t2 = 2.0 * t1; + let noise = ad_pd_2q(t1, t1, t2, t2); + let gate = Gate::identity(2, noise, tau_g); + let pl = synthesize_numerical(&gate, DEFAULT_N_STEPS); + let rates: Vec = PauliString::enumerate_nonidentity(2) + .iter() + .map(|p| pl.rate(p)) + .collect(); + assert!( + max_negative_rate(&rates) < 1e-12, + "negative rate at beta/omega={}: max negative = {}", + r, + max_negative_rate(&rates), + ); + } +} diff --git a/exp/pecos-lindblad/tests/proptest_invariants.rs b/exp/pecos-lindblad/tests/proptest_invariants.rs new file mode 100644 index 000000000..4ca5acccb --- /dev/null +++ b/exp/pecos-lindblad/tests/proptest_invariants.rs @@ -0,0 +1,107 @@ +// 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. + +//! Property-based invariants via proptest. Generates random Lindbladians +//! and verifies: +//! +//! 1. All synthesized PL rates are non-negative (positivity). +//! 2. At τ_g → 0, all rates vanish (no-time, no-error). +//! 3. Rates scale linearly with τ_g at leading order in weak noise. +//! 4. Walsh-Hadamard forward/inverse is a bijection on rate vectors. + +use proptest::prelude::*; + +use pecos_lindblad::noise_models::ad_pd_1q; +use pecos_lindblad::{Gate, Pauli1, PauliLindbladModel, PauliString, synthesize_identity_1q}; + +/// Generate physical `(T_1, T_2)` with `T_2 <= 2 T_1` (GKS-positive regime). +fn t1_t2_strategy() -> impl Strategy { + (10.0f64..1000.0, 0.01f64..1.0).prop_map(|(t1, t2_ratio)| (t1, t2_ratio * 2.0 * t1)) +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 64, ..ProptestConfig::default() })] + + #[test] + fn identity_1q_rates_non_negative((t1, t2) in t1_t2_strategy(), tau_g in 0.01f64..50.0) { + let noise = ad_pd_1q(t1, t2); + let gate = Gate::identity(1, noise, tau_g); + let pl = synthesize_identity_1q(&gate); + for p in [Pauli1::X, Pauli1::Y, Pauli1::Z] { + let r = pl.rate(&PauliString::single(p)); + prop_assert!(r >= 0.0, "rate for {:?} was negative: {}", p, r); + } + } + + #[test] + fn identity_1q_rates_linear_in_tau( + (t1, t2) in t1_t2_strategy(), + tau_a in 0.01f64..5.0, + ) { + let tau_b = tau_a * 2.0; + let noise_a = ad_pd_1q(t1, t2); + let noise_b = ad_pd_1q(t1, t2); + let pl_a = synthesize_identity_1q(&Gate::identity(1, noise_a, tau_a)); + let pl_b = synthesize_identity_1q(&Gate::identity(1, noise_b, tau_b)); + for p in [Pauli1::X, Pauli1::Y, Pauli1::Z] { + let ra = pl_a.rate(&PauliString::single(p)); + let rb = pl_b.rate(&PauliString::single(p)); + // Identity+AD+PD is exact closed form with lambda_k ∝ tau_g. + prop_assert!( + (rb - 2.0 * ra).abs() < 1e-12, + "rate for {:?} not linear in tau: ra={}, rb={}", + p, ra, rb, + ); + } + } +} + +proptest! { + #![proptest_config(ProptestConfig { cases: 32, ..ProptestConfig::default() })] + + /// Generate a random non-negative rate vector and verify the forward + /// Walsh-Hadamard map reproduces alpha_b = 2 Σ λ_k ⟨b,k⟩_sp. + #[test] + fn walsh_hadamard_forward_is_linear_in_rates( + lam_x in 0.0f64..0.1, + lam_y in 0.0f64..0.1, + lam_z in 0.0f64..0.1, + ) { + // Forward relation alpha_b = 2 Σ_k λ_k ⟨b,k⟩_sp for n=1. + // alpha_X = 2(lam_y + lam_z) + // alpha_Y = 2(lam_x + lam_z) + // alpha_Z = 2(lam_x + lam_y) + let supports = vec![ + PauliString::single(Pauli1::X), + PauliString::single(Pauli1::Y), + PauliString::single(Pauli1::Z), + ]; + let rates = vec![lam_x, lam_y, lam_z]; + let model = PauliLindbladModel::new(supports, rates); + + let alpha = |b: &PauliString| -> f64 { + model + .supports + .iter() + .zip(&model.rates) + .map(|(k, lam)| 2.0 * lam * f64::from(b.symplectic_product(k))) + .sum() + }; + + let x = PauliString::single(Pauli1::X); + let y = PauliString::single(Pauli1::Y); + let z = PauliString::single(Pauli1::Z); + prop_assert!((alpha(&x) - 2.0 * (lam_y + lam_z)).abs() < 1e-14); + prop_assert!((alpha(&y) - 2.0 * (lam_x + lam_z)).abs() < 1e-14); + prop_assert!((alpha(&z) - 2.0 * (lam_x + lam_y)).abs() < 1e-14); + } +} diff --git a/exp/pecos-lindblad/tests/sample_statistics.rs b/exp/pecos-lindblad/tests/sample_statistics.rs new file mode 100644 index 000000000..d8736b5b6 --- /dev/null +++ b/exp/pecos-lindblad/tests/sample_statistics.rs @@ -0,0 +1,131 @@ +// 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 validation of [`PauliLindbladModel::sample`]. Build a +//! known PL model and verify empirical flip probabilities match the +//! analytical per-term formula `p_k = (1 - exp(-2 lambda_k t)) / 2`. +//! +//! Uses a binomial-CI tolerance of `5 sigma = 5 * sqrt(p(1-p)/N)` to keep +//! flakiness very low. + +use rand::SeedableRng; +use rand::rngs::StdRng; + +use pecos_lindblad::{Pauli1, PauliLindbladModel, PauliString}; + +#[test] +fn single_term_flip_probability_matches_formula() { + // 1Q PL with only lambda_x non-zero. + let lambda_x = 0.05; + let t_scale = 1.0; + let expected_p: f64 = 0.5 * (1.0 - f64::exp(-2.0 * lambda_x * t_scale)); + let model = PauliLindbladModel::new( + vec![ + PauliString::single(Pauli1::X), + PauliString::single(Pauli1::Y), + PauliString::single(Pauli1::Z), + ], + vec![lambda_x, 0.0, 0.0], + ); + + let n_samples: usize = 100_000; + let mut rng = StdRng::seed_from_u64(12345); + let mut x_hits = 0usize; + let mut y_hits = 0usize; + let mut z_hits = 0usize; + for _ in 0..n_samples { + let s = model.sample(t_scale, &mut rng); + let ch = s.0[0]; + match ch { + Pauli1::X => x_hits += 1, + Pauli1::Y => y_hits += 1, + Pauli1::Z => z_hits += 1, + Pauli1::I => {} + } + } + + let p_hat_x = x_hits as f64 / n_samples as f64; + let sigma = (expected_p * (1.0 - expected_p) / n_samples as f64).sqrt(); + let tol = 5.0 * sigma; + assert!( + (p_hat_x - expected_p).abs() < tol, + "X flip rate: got {}, expected {}, diff {} > 5 sigma {}", + p_hat_x, + expected_p, + (p_hat_x - expected_p).abs(), + tol, + ); + // Y/Z rates are 0; allow a small count due to X*X*X*X etc. not firing. + assert_eq!(y_hits, 0, "Y should never fire (lambda_y = 0)"); + assert_eq!(z_hits, 0, "Z should never fire (lambda_z = 0)"); +} + +#[test] +fn multi_term_sample_respects_independent_bernoullis() { + // Two independent Pauli terms. Empirical joint distribution over + // {I, X, Z, XZ=Y} should match the 2x2 independent Bernoulli table. + let lambda_x = 0.02; + let lambda_z = 0.03; + let t_scale = 1.0; + let p_x: f64 = 0.5 * (1.0 - f64::exp(-2.0 * lambda_x * t_scale)); + let p_z: f64 = 0.5 * (1.0 - f64::exp(-2.0 * lambda_z * t_scale)); + + let model = PauliLindbladModel::new( + vec![ + PauliString::single(Pauli1::X), + PauliString::single(Pauli1::Z), + ], + vec![lambda_x, lambda_z], + ); + + let n_samples: usize = 200_000; + let mut rng = StdRng::seed_from_u64(7); + // Bucket counts: [I, X, Y, Z]. Note X*Z (unordered) = Y in our + // sign-dropped multiplication table. + let mut counts = [0usize; 4]; + for _ in 0..n_samples { + let s = model.sample(t_scale, &mut rng); + let idx = match s.0[0] { + Pauli1::I => 0, + Pauli1::X => 1, + Pauli1::Y => 2, + Pauli1::Z => 3, + }; + counts[idx] += 1; + } + let emp = |k: usize| counts[k] as f64 / n_samples as f64; + + // Analytical: + // P(I) = (1-p_x)(1-p_z) + // P(X) = p_x (1 - p_z) + // P(Z) = (1 - p_x) p_z + // P(Y) = p_x p_z + let e_i = (1.0 - p_x) * (1.0 - p_z); + let e_x = p_x * (1.0 - p_z); + let e_z = (1.0 - p_x) * p_z; + let e_y = p_x * p_z; + + for (idx, expected) in [(0, e_i), (1, e_x), (2, e_y), (3, e_z)] { + let got = emp(idx); + let sigma = (expected * (1.0 - expected) / n_samples as f64).sqrt(); + let tol = 5.0 * sigma; + assert!( + (got - expected).abs() < tol, + "bucket {}: got {}, expected {}, diff {} > 5 sigma {}", + idx, + got, + expected, + (got - expected).abs(), + tol, + ); + } +} diff --git a/exp/pecos-lindblad/tests/serde_roundtrip.rs b/exp/pecos-lindblad/tests/serde_roundtrip.rs new file mode 100644 index 000000000..8d9f99834 --- /dev/null +++ b/exp/pecos-lindblad/tests/serde_roundtrip.rs @@ -0,0 +1,82 @@ +// 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. + +//! Serde round-trip tests (gated on the `serde` feature). Users can cache +//! expensive synthesis results and reload them later. + +#![cfg(feature = "serde")] + +use pecos_lindblad::noise_models::ad_pd_2q; +use pecos_lindblad::{ + DEFAULT_N_STEPS, Gate, Pauli1, PauliLindbladModel, PauliString, synthesize_numerical, +}; + +#[test] +fn pauli1_round_trip() { + for p in [Pauli1::I, Pauli1::X, Pauli1::Y, Pauli1::Z] { + let json = serde_json::to_string(&p).unwrap(); + let restored: Pauli1 = serde_json::from_str(&json).unwrap(); + assert_eq!(p, restored); + } +} + +#[test] +fn pauli_string_round_trip() { + for s in ["I", "X", "Y", "Z", "IX", "XYZI", "ZZZZZ"] { + let ps = PauliString::from_label(s).unwrap(); + let json = serde_json::to_string(&ps).unwrap(); + let restored: PauliString = serde_json::from_str(&json).unwrap(); + assert_eq!(ps, restored); + } +} + +#[test] +fn pauli_lindblad_model_round_trip_via_cx_theta() { + // Synthesize a non-trivial 2Q CX_theta model, serialize, and verify + // round-trip is bit-exact. + let t1 = 100.0; + let t2 = 80.0; + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + let noise = ad_pd_2q(t1, t1, t2, t2); + let gate = Gate::cx_theta(omega, theta, noise); + let pl = synthesize_numerical(&gate, DEFAULT_N_STEPS); + + let json = serde_json::to_string(&pl).unwrap(); + let restored: PauliLindbladModel = serde_json::from_str(&json).unwrap(); + + assert_eq!(pl.supports.len(), restored.supports.len()); + for (a, b) in pl.supports.iter().zip(&restored.supports) { + assert_eq!(a, b); + } + for (a, b) in pl.rates.iter().zip(&restored.rates) { + assert_eq!(a.to_bits(), b.to_bits(), "rate mismatch {} vs {}", a, b); + } +} + +#[test] +fn pauli_lindblad_model_json_is_human_readable() { + // Sanity: verify the JSON shape is predictable enough for users to + // inspect / hand-edit. + let pl = PauliLindbladModel::new( + vec![ + PauliString::from_label("X").unwrap(), + PauliString::from_label("Z").unwrap(), + ], + vec![0.001, 0.002], + ); + let json = serde_json::to_string(&pl).unwrap(); + // Expect something like: {"supports":[...],"rates":[0.001,0.002]} + assert!(json.contains("\"rates\"")); + assert!(json.contains("\"supports\"")); + assert!(json.contains("0.001") || json.contains("1e-3")); +} diff --git a/exp/pecos-lindblad/tests/superop_synthesis.rs b/exp/pecos-lindblad/tests/superop_synthesis.rs new file mode 100644 index 000000000..4bdbe4bc4 --- /dev/null +++ b/exp/pecos-lindblad/tests/superop_synthesis.rs @@ -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. + +//! Tests for `synthesize_superop_identity` -- the unified path for +//! identity gates with mixed coherent + dissipative noise. Validates +//! consistency with: +//! +//! - `synthesize_identity_1q` (pure dissipative AD+PD) +//! - `synthesize_exact_unitary` (pure coherent) +//! +//! and exercises the **new** mixed case (both at once) that the other two +//! entry points reject or under-model. + +use approx::assert_abs_diff_eq; +use num_complex::Complex64; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::noise_models::{ad_pd_1q, coherent_phase_2q}; +use pecos_lindblad::{ + Gate, Lindbladian, Pauli1, PauliString, synthesize_exact_unitary, synthesize_identity_1q, + synthesize_superop_identity, +}; + +#[test] +fn superop_identity_matches_fast_ad_pd_1q() { + // Pure AD+PD, 1Q identity. Superop path should match fast closed-form + // path to machine precision. + let t1 = 100.0; + let t2 = 80.0; + let tau_g = 5.0; + let noise = ad_pd_1q(t1, t2); + let gate = Gate::identity(1, noise, tau_g); + + let fast = synthesize_identity_1q(&gate); + let superop = synthesize_superop_identity(&gate); + + for p in [Pauli1::X, Pauli1::Y, Pauli1::Z] { + let key = PauliString::single(p); + assert_abs_diff_eq!(fast.rate(&key), superop.rate(&key), epsilon = 1e-10); + } +} + +#[test] +fn superop_identity_matches_exact_unitary_for_pure_coherent() { + // 2Q identity with pure coherent phase noise: both exact_unitary and + // superop paths should agree. + let tau_g = 10.0; + let delta_iz = 1e-5; + let delta_zi = 2e-5; + let delta_zz = 5e-6; + let noise = coherent_phase_2q(delta_iz, delta_zi, delta_zz); + let gate = Gate::identity(2, noise, tau_g); + + let exact = synthesize_exact_unitary(&gate); + let superop = synthesize_superop_identity(&gate); + + for ps in PauliString::enumerate_nonidentity(2) { + assert_abs_diff_eq!(exact.rate(&ps), superop.rate(&ps), epsilon = 1e-10); + } +} + +#[test] +fn superop_identity_handles_mixed_coherent_and_dissipative_2q() { + // THE new capability: simultaneous AD+PD AND coherent crosstalk on + // an identity gate. Neither `synthesize_identity_1q` (1Q only) nor + // `synthesize_exact_unitary` (coherent only) can handle this; + // `synthesize_numerical` catches only the Omega_1 dissipative part. + // Only `synthesize_superop_identity` gives the full answer. + let d = 4; + let tau_g = 1.0; + let beta_down = 1e-4; + let beta_phi = 2e-4; + let delta_zz = 1e-3; + + // Hamiltonian part: (delta_zz / 2) ZZ coherent + let i2 = matrix::identity(2); + let z = matrix::pauli_1q(Pauli1::Z); + let zz = matrix::kron(&z, &z, 2, 2); + let h_delta = matrix::scale(&zz, Complex64::new(delta_zz / 2.0, 0.0)); + + // Dissipator part: AD+PD on both qubits + let sm = matrix::sigma_minus(); + let sm_l = matrix::kron(&sm, &i2, 2, 2); + let sm_r = matrix::kron(&i2, &sm, 2, 2); + let z_l = matrix::kron(&z, &i2, 2, 2); + let z_r = matrix::kron(&i2, &z, 2, 2); + let collapse: Vec<(Matrix, f64)> = vec![ + (sm_l, beta_down), + (sm_r, beta_down), + (z_l, beta_phi / 2.0), + (z_r, beta_phi / 2.0), + ]; + let mixed = Lindbladian::new(d, h_delta, collapse); + let gate = Gate::identity(2, mixed, tau_g); + let pl = synthesize_superop_identity(&gate); + + let rate = |s: &str| pl.rate(&PauliString::from_label(s).unwrap()); + + // Expected (leading-order superposition of independent contributions): + // Dissipative (AD+PD) on each qubit contributes single-qubit rates + // (paper line 812): lambda_{i·x,y} = beta_down * tau / 4, + // lambda_{i·z} = beta_phi * tau / 2 (mirror for l). + // Coherent ZZ contributes lambda_ZZ = (delta_zz * tau)^2 / 4 + // (paper eq. 981 identity case). + // Cross terms between dissipative and coherent are O(beta * delta * tau^2) + // and small for these parameter values. + let expected_ix_iy = beta_down * tau_g / 4.0; + let expected_iz = beta_phi * tau_g / 2.0; + let expected_zz = (delta_zz * tau_g).powi(2) / 4.0; + let tol_dissipative = 1e-10; + let tol_coherent = 1e-10; + + assert_abs_diff_eq!(rate("IX"), expected_ix_iy, epsilon = tol_dissipative); + assert_abs_diff_eq!(rate("IY"), expected_ix_iy, epsilon = tol_dissipative); + assert_abs_diff_eq!(rate("XI"), expected_ix_iy, epsilon = tol_dissipative); + assert_abs_diff_eq!(rate("YI"), expected_ix_iy, epsilon = tol_dissipative); + assert_abs_diff_eq!(rate("IZ"), expected_iz, epsilon = tol_dissipative); + assert_abs_diff_eq!(rate("ZI"), expected_iz, epsilon = tol_dissipative); + assert_abs_diff_eq!(rate("ZZ"), expected_zz, epsilon = tol_coherent); +} diff --git a/exp/pecos-lindblad/tests/synthesize_superop_full.rs b/exp/pecos-lindblad/tests/synthesize_superop_full.rs new file mode 100644 index 000000000..628b66010 --- /dev/null +++ b/exp/pecos-lindblad/tests/synthesize_superop_full.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. + +//! Tests for `synthesize_superop` -- the fully-general time-sliced path +//! that handles `H_g != 0` AND simultaneous coherent + dissipative noise. +//! +//! Three validation modes: +//! 1. Matches `synthesize_numerical` on pure dissipative inputs (CX+AD+PD). +//! 2. Matches `synthesize_exact_unitary` on pure coherent inputs (CX+phase). +//! 3. NEW: handles mixed input that neither of the above covers +//! (CX_theta + AD+PD + coherent ZZ phase, all at once). + +use approx::assert_abs_diff_eq; +use num_complex::Complex64; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::noise_models::{ad_pd_2q, coherent_phase_2q}; +use pecos_lindblad::{ + DEFAULT_N_SLICES, DEFAULT_N_STEPS, Gate, Lindbladian, Pauli1, PauliString, + synthesize_exact_unitary, synthesize_numerical, synthesize_superop, +}; + +#[test] +fn superop_matches_synthesize_numerical_cx_ad_pd() { + // Weak dissipative noise on CX_theta: superop (all orders) and + // synthesize_numerical (leading order) should agree to high precision. + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + let noise = ad_pd_2q(1e5, 1e5, 8e4, 8e4); // very weak + let gate = Gate::cx_theta(omega, theta, noise); + + let simpson = synthesize_numerical(&gate, DEFAULT_N_STEPS); + let superop = synthesize_superop(&gate, DEFAULT_N_SLICES); + + for ps in PauliString::enumerate_nonidentity(2) { + // At this noise level (~1e-5), O(beta^2) corrections are ~1e-10 + // and negligible. Expect agreement to ~1e-9. + assert_abs_diff_eq!(simpson.rate(&ps), superop.rate(&ps), epsilon = 1e-9); + } +} + +#[test] +fn superop_matches_synthesize_exact_unitary_cx_coherent() { + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + let noise = coherent_phase_2q(1e-5, 2e-5, 5e-6); + let gate = Gate::cx_theta(omega, theta, noise); + + let exact = synthesize_exact_unitary(&gate); + let superop = synthesize_superop(&gate, DEFAULT_N_SLICES); + + for ps in PauliString::enumerate_nonidentity(2) { + assert_abs_diff_eq!(exact.rate(&ps), superop.rate(&ps), epsilon = 1e-10); + } +} + +#[test] +fn superop_handles_cx_mixed_ad_pd_plus_coherent_zz() { + // THE new capability: CX_theta with simultaneous AD+PD dissipators + // AND coherent ZZ phase noise. No other synthesis path can do this: + // - synthesize_numerical: dissipative leading-order, loses coherent + // quadratic contribution. + // - synthesize_exact_unitary: asserts no c_ops, refuses mixed input. + // - synthesize_superop_identity: requires H_g = 0. + let d = 4; + let omega = 1.0; + let theta = std::f64::consts::FRAC_PI_4; + let t1 = 1e5; // very weak AD + let t2 = 8e4; // weak PD + let delta_zz = 1e-4; // weak coherent ZZ + + let i2 = matrix::identity(2); + let sm = matrix::sigma_minus(); + let z = matrix::pauli_1q(Pauli1::Z); + let sm_l = matrix::kron(&sm, &i2, 2, 2); + let sm_r = matrix::kron(&i2, &sm, 2, 2); + let z_l = matrix::kron(&z, &i2, 2, 2); + let z_r = matrix::kron(&i2, &z, 2, 2); + let zz = matrix::kron(&z, &z, 2, 2); + + let beta_down = 1.0 / t1; + let beta_phi = 1.0 / t2 - 1.0 / (2.0 * t1); + let h_delta = matrix::scale(&zz, Complex64::new(delta_zz / 2.0, 0.0)); + let collapse: Vec<(Matrix, f64)> = vec![ + (sm_l, beta_down), + (sm_r, beta_down), + (z_l, beta_phi / 2.0), + (z_r, beta_phi / 2.0), + ]; + let mixed = Lindbladian::new(d, h_delta, collapse); + let mixed_gate = Gate::cx_theta(omega, theta, mixed); + + // Also build pure-dissipative and pure-coherent variants for + // superposition comparison. + let pure_diss = ad_pd_2q(t1, t1, t2, t2); + let diss_gate = Gate::cx_theta(omega, theta, pure_diss); + let pure_coh = coherent_phase_2q(0.0, 0.0, delta_zz); + let coh_gate = Gate::cx_theta(omega, theta, pure_coh); + + let pl_mixed = synthesize_superop(&mixed_gate, DEFAULT_N_SLICES); + let pl_diss = synthesize_superop(&diss_gate, DEFAULT_N_SLICES); + let pl_coh = synthesize_superop(&coh_gate, DEFAULT_N_SLICES); + + // At weak coupling, the mixed rates should equal the superposition of + // the two individual-noise rates (cross-terms are second-order small). + for ps in PauliString::enumerate_nonidentity(2) { + let expected = pl_diss.rate(&ps) + pl_coh.rate(&ps); + let got = pl_mixed.rate(&ps); + // Cross-term O(beta * delta * tau^2) ~ 1e-5 * 1e-4 * 1 = 1e-9. + // Use tolerance slightly above that to account for MC/numerical noise. + assert_abs_diff_eq!(got, expected, epsilon = 1e-8); + } + + // Additional sanity: mixed has non-trivial rates from both sources. + let rate_mixed = |s: &str| pl_mixed.rate(&PauliString::from_label(s).unwrap()); + assert!( + rate_mixed("IX") > 1e-8, + "dissipative contribution should be present" + ); + assert!( + rate_mixed("ZZ") > 1e-10, + "coherent ZZ contribution should be present" + ); +} diff --git a/exp/pecos-lindblad/tests/uncertainty_propagation.rs b/exp/pecos-lindblad/tests/uncertainty_propagation.rs new file mode 100644 index 000000000..960c5b9db --- /dev/null +++ b/exp/pecos-lindblad/tests/uncertainty_propagation.rs @@ -0,0 +1,127 @@ +// 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. + +//! Monte-Carlo uncertainty propagation tests. For 1Q identity + AD+PD the +//! rates are exactly `lambda_x = lambda_y = beta_down tau/4` and +//! `lambda_z = beta_phi tau/2`, so uncertainty propagation is analytic: +//! +//! d lambda_x / d T_1 = -tau / (4 T_1^2) +//! d lambda_z / d T_1 = -tau / (2 * 2 T_1^2) = -tau / (4 T_1^2) (from 1/(2T_1)) +//! d lambda_z / d T_2 = -tau / (2 T_2^2) +//! +//! For small Gaussian jitter, first-order MC std should match these +//! derivatives times the input sigma, to within Monte-Carlo error. + +use rand::rngs::StdRng; +use rand::{RngExt, SeedableRng}; + +use pecos_lindblad::noise_models::{ad_pd_1q, propagate_uncertainty}; +use pecos_lindblad::{Gate, Pauli1, PauliString, synthesize_identity_1q}; + +fn gaussian(rng: &mut StdRng, mean: f64, sigma: f64) -> f64 { + // Box-Muller. + let u1: f64 = rng.random_range(1e-15f64..1.0f64); + let u2: f64 = rng.random_range(0.0f64..1.0f64); + mean + sigma * f64::sqrt(-2.0 * f64::ln(u1)) * f64::cos(2.0 * std::f64::consts::PI * u2) +} + +#[test] +fn uncertainty_propagation_gives_expected_std_scale() { + let t1_mean = 100.0; + let t2_mean = 80.0; + let t1_sigma = 5.0; // 5% relative + let t2_sigma = 4.0; // 5% relative + let tau_g = 1.0; + let n_samples = 2000; + + let mut rng = StdRng::seed_from_u64(42); + let unc = propagate_uncertainty(n_samples, |_k| { + let t1 = gaussian(&mut rng, t1_mean, t1_sigma).max(t2_mean / 2.0 + 1e-3); + let t2 = gaussian(&mut rng, t2_mean, t2_sigma) + .min(2.0 * t1) + .max(1e-3); + let noise = ad_pd_1q(t1, t2); + let gate = Gate::identity(1, noise, tau_g); + synthesize_identity_1q(&gate) + }); + + // Sanity: means should be close to central-parameter prediction. + let beta_down = 1.0 / t1_mean; + let beta_phi = 1.0 / t2_mean - 1.0 / (2.0 * t1_mean); + let expected_mean_x = beta_down * tau_g / 4.0; + let expected_mean_z = beta_phi * tau_g / 2.0; + + let got_mean_x = unc.mean(&PauliString::single(Pauli1::X)); + let got_mean_z = unc.mean(&PauliString::single(Pauli1::Z)); + // Monte-Carlo sample-mean error ~ std / sqrt(N); allow 5 sigma. + let got_std_x = unc.std(&PauliString::single(Pauli1::X)); + let got_std_z = unc.std(&PauliString::single(Pauli1::Z)); + let mean_err_tol_x = 5.0 * got_std_x / (n_samples as f64).sqrt(); + let mean_err_tol_z = 5.0 * got_std_z / (n_samples as f64).sqrt(); + // Add small bias tolerance for the boundary clamping above. + assert!( + (got_mean_x - expected_mean_x).abs() < 5.0 * mean_err_tol_x + 0.05 * expected_mean_x, + "mean_x: got {}, expected {}, diff {}", + got_mean_x, + expected_mean_x, + (got_mean_x - expected_mean_x).abs(), + ); + assert!( + (got_mean_z - expected_mean_z).abs() < 5.0 * mean_err_tol_z + 0.05 * expected_mean_z, + "mean_z: got {}, expected {}, diff {}", + got_mean_z, + expected_mean_z, + (got_mean_z - expected_mean_z).abs(), + ); + + // Std should be non-trivial (not collapsed to zero) and in the right ballpark + // via first-order propagation: sigma_lambda_x ~ tau/(4 T_1^2) * sigma_T1. + let predicted_sigma_x = tau_g / (4.0 * t1_mean.powi(2)) * t1_sigma; + assert!( + got_std_x > 0.3 * predicted_sigma_x && got_std_x < 3.0 * predicted_sigma_x, + "sigma_x: got {}, predicted (1st-order) {}, out of factor-3 range", + got_std_x, + predicted_sigma_x, + ); +} + +#[test] +fn uncertainty_std_scales_linearly_with_input_sigma() { + // Double the input sigma -> output sigma should roughly double + // (first-order Taylor expansion). + let t1_mean = 100.0; + let t2_mean = 80.0; + let tau_g = 1.0; + + let run = |sigma_t1: f64, seed: u64| -> f64 { + let mut rng = StdRng::seed_from_u64(seed); + let unc = propagate_uncertainty(1000, |_k| { + let t1 = gaussian(&mut rng, t1_mean, sigma_t1).max(t2_mean / 2.0 + 1e-3); + let noise = ad_pd_1q(t1, t2_mean); + let gate = Gate::identity(1, noise, tau_g); + synthesize_identity_1q(&gate) + }); + unc.std(&PauliString::single(Pauli1::X)) + }; + + let s1 = run(2.0, 11); + let s2 = run(4.0, 11); + // Ratio should be ~2, allow 25% slack for MC noise. + let ratio = s2 / s1; + assert!( + (ratio - 2.0).abs() < 0.5, + "std did not scale linearly: s1={}, s2={}, ratio={}", + s1, + s2, + ratio + ); +} diff --git a/exp/pecos-lindblad/tests/walsh_hadamard_roundtrip.rs b/exp/pecos-lindblad/tests/walsh_hadamard_roundtrip.rs new file mode 100644 index 000000000..55ad8a78a --- /dev/null +++ b/exp/pecos-lindblad/tests/walsh_hadamard_roundtrip.rs @@ -0,0 +1,114 @@ +// 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. + +//! Walsh-Hadamard forward/inverse consistency. Starting from arbitrary +//! non-negative rates `{lambda_k}`: +//! +//! 1. Forward map: `alpha_b = 2 * sum_k lambda_k * _sp`. +//! 2. Invert via Walsh-Hadamard: `lambda'_k = -(1/4^n) * sum_b (-1)^{_sp} * alpha_b`. +//! 3. Verify `lambda'_k == lambda_k` (self-consistency of the algorithm). +//! +//! This closes a gap in the existing coverage: paper-fixture tests verify +//! the round-trip end-to-end but not the Walsh-Hadamard step in isolation, +//! so a bug there could cancel against a parallel bug in synthesis. + +use approx::assert_abs_diff_eq; + +use pecos_lindblad::{Pauli1, PauliLindbladModel, PauliString}; + +fn forward_alpha(model: &PauliLindbladModel, b: &PauliString) -> f64 { + // alpha_b = 2 * sum_k lambda_k * _sp. + model + .supports + .iter() + .zip(&model.rates) + .map(|(k, lam)| 2.0 * lam * f64::from(b.symplectic_product(k))) + .sum() +} + +fn inverse_walsh_hadamard(paulis: &[PauliString], alphas: &[f64], n_qubits: usize) -> Vec { + // lambda_k = -(1/4^n) * sum_b (-1)^{_sp} * alpha_b (paper App B). + let norm = 1.0 / (1usize << (2 * n_qubits)) as f64; + paulis + .iter() + .map(|k| { + let s: f64 = paulis + .iter() + .zip(alphas.iter()) + .map(|(b, &ab)| { + let sign = if k.symplectic_product(b) == 0 { + 1.0 + } else { + -1.0 + }; + sign * ab + }) + .sum(); + -norm * s + }) + .collect() +} + +fn round_trip(n_qubits: usize, seed_rates: &[(&str, f64)]) { + let supports: Vec = seed_rates + .iter() + .map(|(s, _)| PauliString::from_label(s).unwrap()) + .collect(); + let rates: Vec = seed_rates.iter().map(|(_, r)| *r).collect(); + let model = PauliLindbladModel::new(supports.clone(), rates.clone()); + + // Enumerate all non-identity paulis to get alpha_b for each. + let all = PauliString::enumerate_nonidentity(n_qubits); + let alphas: Vec = all.iter().map(|b| forward_alpha(&model, b)).collect(); + let recovered = inverse_walsh_hadamard(&all, &alphas, n_qubits); + + // Build the "true" rates aligned to `all` order (0 for unseeded supports). + let mut expected = vec![0.0; all.len()]; + for (s, r) in &rates + .iter() + .zip(&supports) + .map(|(r, s)| (s.clone(), *r)) + .collect::>() + { + if let Some(idx) = all.iter().position(|p| p == s) { + expected[idx] = *r; + } + } + for (got, want) in recovered.iter().zip(expected.iter()) { + assert_abs_diff_eq!(got, want, epsilon = 1e-12); + } +} + +#[test] +fn walsh_hadamard_round_trip_1q() { + round_trip(1, &[("X", 0.001), ("Y", 0.002), ("Z", 0.003)]); +} + +#[test] +fn walsh_hadamard_round_trip_2q_sparse() { + round_trip(2, &[("IX", 1e-3), ("IZ", 2e-3), ("XI", 3e-3), ("ZZ", 4e-4)]); +} + +#[test] +fn walsh_hadamard_round_trip_3q_with_weight_3() { + round_trip( + 3, + &[("IYZ", 1e-4), ("IZZ", 2e-4), ("ZZZ", 5e-5), ("XII", 1e-3)], + ); +} + +#[test] +fn walsh_hadamard_round_trip_dense_1q() { + // Dense 1Q (all 3 non-identity rates set) to exercise every row. + let _ = Pauli1::X; // re-export reachable + round_trip(1, &[("X", 0.1), ("Y", 0.2), ("Z", 0.3)]); +} diff --git a/exp/pecos-lindblad/tests/x_theta_1q.rs b/exp/pecos-lindblad/tests/x_theta_1q.rs new file mode 100644 index 000000000..ae5705c72 --- /dev/null +++ b/exp/pecos-lindblad/tests/x_theta_1q.rs @@ -0,0 +1,134 @@ +// 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. + +//! Parity test: 1-qubit X_theta gate under amplitude damping + pure +//! dephasing vs closed-form leading-order results from arXiv:2502.03462 +//! eqs. 869-874 (appendix SubApp:X_th+AD+PD). +//! +//! Paper closed forms (lambda_k are dimensionless, integrated over the gate): +//! lambda_x = (theta / 4) * (beta_down / omega_x) +//! lambda_y = ((2 theta + sin 2 theta) / 16) * (beta_down / omega_x) +//! + ((2 theta - sin 2 theta) / 8) * (beta_phi / omega_x) +//! lambda_z = ((2 theta - sin 2 theta) / 16) * (beta_down / omega_x) +//! + ((2 theta + sin 2 theta) / 8) * (beta_phi / omega_x) +//! +//! The paper's approximation is "leading order in beta/omega"; at +//! `beta/omega ~ 1e-2` deviation should be ~`O(1e-5)` per +//! Appendix `App:LindPertPrecision` (line 1078). + +use approx::assert_abs_diff_eq; + +use pecos_lindblad::matrix::{self, Matrix}; +use pecos_lindblad::{ + DEFAULT_N_STEPS, Gate, Lindbladian, Pauli1, PauliString, synthesize_numerical_1q, +}; + +fn ad_plus_pd_noise(beta_down: f64, beta_phi: f64) -> Lindbladian { + let d = 2; + let hamiltonian = matrix::zeros(d); + let collapse: Vec<(Matrix, f64)> = vec![ + (matrix::sigma_minus(), beta_down), + (matrix::pauli_1q(Pauli1::Z), beta_phi / 2.0), + ]; + Lindbladian::new(d, hamiltonian, collapse) +} + +fn paper_closed_form(theta: f64, omega_x: f64, beta_down: f64, beta_phi: f64) -> (f64, f64, f64) { + let two_t = 2.0 * theta; + let sin_2t = two_t.sin(); + let lambda_x = (theta / 4.0) * (beta_down / omega_x); + let lambda_y = ((two_t + sin_2t) / 16.0) * (beta_down / omega_x) + + ((two_t - sin_2t) / 8.0) * (beta_phi / omega_x); + let lambda_z = ((two_t - sin_2t) / 16.0) * (beta_down / omega_x) + + ((two_t + sin_2t) / 8.0) * (beta_phi / omega_x); + (lambda_x, lambda_y, lambda_z) +} + +fn run_and_compare(theta: f64, omega_x: f64, beta_down: f64, beta_phi: f64, tol: f64) { + let noise = ad_plus_pd_noise(beta_down, beta_phi); + let gate = Gate::x_theta(omega_x, theta, noise); + let pl = synthesize_numerical_1q(&gate, DEFAULT_N_STEPS); + + let (expected_x, expected_y, expected_z) = + paper_closed_form(theta, omega_x, beta_down, beta_phi); + + let got_x = pl.rate(&PauliString::single(Pauli1::X)); + let got_y = pl.rate(&PauliString::single(Pauli1::Y)); + let got_z = pl.rate(&PauliString::single(Pauli1::Z)); + + assert_abs_diff_eq!(got_x, expected_x, epsilon = tol); + assert_abs_diff_eq!(got_y, expected_y, epsilon = tol); + assert_abs_diff_eq!(got_z, expected_z, epsilon = tol); +} + +#[test] +fn x_theta_ad_plus_pd_pi_over_4() { + // Weak noise => numerical integrand matches leading-order closed form + // to within ~O(beta^2 / omega^2). Use beta/omega = 1e-4 => tol ~1e-8. + let omega_x = 1.0; + let beta_down = 1e-4; + let beta_phi = 2e-4; + let theta = std::f64::consts::FRAC_PI_4; + run_and_compare(theta, omega_x, beta_down, beta_phi, 1e-8); +} + +#[test] +fn x_theta_ad_plus_pd_pi_over_2() { + let omega_x = 1.0; + let beta_down = 1e-4; + let beta_phi = 5e-5; + let theta = std::f64::consts::FRAC_PI_2; + run_and_compare(theta, omega_x, beta_down, beta_phi, 1e-8); +} + +#[test] +fn x_theta_ad_only_pi_over_3() { + // lambda_x = (theta/4)(beta_down/omega_x), lambda_y and lambda_z from + // beta_down only. + let omega_x = 2.0; + let beta_down = 3e-4; + let beta_phi = 0.0; + let theta = std::f64::consts::FRAC_PI_3; + run_and_compare(theta, omega_x, beta_down, beta_phi, 1e-8); +} + +#[test] +fn x_theta_pd_only_pi_over_2() { + // lambda_x = 0 (no AD). + let omega_x = 1.0; + let beta_down = 0.0; + let beta_phi = 4e-4; + let theta = std::f64::consts::FRAC_PI_2; + run_and_compare(theta, omega_x, beta_down, beta_phi, 1e-8); +} + +#[test] +fn numerical_1q_reduces_to_identity() { + // synthesize_numerical_1q on an identity gate (H_g = 0) should match + // the fast-path identity synthesis to high precision. Exercises the + // time-integral path on a constant integrand. + use pecos_lindblad::synthesize_identity_1q; + + let beta_down = 1e-4; + let beta_phi = 3e-4; + let tau_g = 50.0; + let noise = ad_plus_pd_noise(beta_down, beta_phi); + let gate = Gate::identity(1, noise, tau_g); + + let fast = synthesize_identity_1q(&gate); + let numerical = synthesize_numerical_1q(&gate, DEFAULT_N_STEPS); + + for p in [Pauli1::X, Pauli1::Y, Pauli1::Z] { + let key = PauliString::single(p); + assert_abs_diff_eq!(fast.rate(&key), numerical.rate(&key), epsilon = 1e-12); + } +} diff --git a/exp/pecos-neo/Cargo.toml b/exp/pecos-neo/Cargo.toml index 115fa6e10..69786fbac 100644 --- a/exp/pecos-neo/Cargo.toml +++ b/exp/pecos-neo/Cargo.toml @@ -28,6 +28,7 @@ num_cpus = "1.16" # Optional: for adapter to wrap classical engines pecos-engines = { workspace = true, optional = true } + # Optional: for program types (Qasm, Program enum, etc.) pecos-programs = { workspace = true, optional = true } # Optional: for QASM support @@ -37,6 +38,7 @@ pecos-hugr = { workspace = true, optional = true } [dev-dependencies] rand.workspace = true +num-complex.workspace = true pecos-engines.workspace = true pecos-qasm.workspace = true pecos-hugr.workspace = true diff --git a/exp/pecos-neo/src/adapter.rs b/exp/pecos-neo/src/adapter.rs index 1d9c2f9d2..51d7a3cee 100644 --- a/exp/pecos-neo/src/adapter.rs +++ b/exp/pecos-neo/src/adapter.rs @@ -29,8 +29,9 @@ //! //! ## Example //! -#![cfg_attr(feature = "engines-adapter", doc = "```no_run")] -#![cfg_attr(not(feature = "engines-adapter"), doc = "```ignore")] +//! ```rust,no_run +//! #[cfg(feature = "engines-adapter")] +//! fn example() { //! use std::str::FromStr; //! use pecos_neo::adapter::ClassicalEngineAdapter; //! use pecos_neo::prelude::*; @@ -61,6 +62,7 @@ //! .with_noise(noise); //! //! let result = runner.run_shot(&mut program); +//! } //! ``` use crate::command::{CommandQueue, GateCommand, GateType as NeoGateType}; @@ -85,6 +87,8 @@ fn convert_gate_type(core_type: CoreGateType) -> Option { // Single-qubit Cliffords CoreGateType::H => NeoGateType::H, + CoreGateType::F => NeoGateType::F, + CoreGateType::Fdg => NeoGateType::Fdg, CoreGateType::SX => NeoGateType::SX, CoreGateType::SXdg => NeoGateType::SXdg, CoreGateType::SY => NeoGateType::SY, @@ -107,6 +111,10 @@ fn convert_gate_type(core_type: CoreGateType) -> Option { CoreGateType::CZ => NeoGateType::CZ, CoreGateType::SZZ => NeoGateType::SZZ, CoreGateType::SZZdg => NeoGateType::SZZdg, + CoreGateType::SXX => NeoGateType::SXX, + CoreGateType::SXXdg => NeoGateType::SXXdg, + CoreGateType::SYY => NeoGateType::SYY, + CoreGateType::SYYdg => NeoGateType::SYYdg, CoreGateType::SWAP => NeoGateType::SWAP, CoreGateType::CRZ => NeoGateType::CRZ, CoreGateType::RXX => NeoGateType::RXX, @@ -133,15 +141,18 @@ fn convert_gate_type(core_type: CoreGateType) -> Option { } /// Convert a pecos-core `Gate` to a pecos-neo `GateCommand`. -/// -/// Returns `None` if the gate type is not supported. -fn convert_gate(gate: &Gate) -> Option { - let neo_type = convert_gate_type(gate.gate_type)?; +fn convert_gate(gate: &Gate) -> Result { + let neo_type = convert_gate_type(gate.gate_type).ok_or_else(|| { + pecos_core::errors::PecosError::Input(format!( + "pecos-neo adapter does not support gate type {:?}", + gate.gate_type + )) + })?; let qubits = gate.qubits.iter().copied().collect(); let angles = gate.angles.iter().copied().collect(); - Some(GateCommand { + Ok(GateCommand { gate_type: neo_type, qubits, angles, @@ -150,10 +161,9 @@ fn convert_gate(gate: &Gate) -> Option { /// Convert a `ByteMessage` containing quantum operations to a `CommandQueue`. /// -/// Skips any gates that can't be converted (with a warning in debug mode). -/// /// # Errors -/// Returns `PecosError` if the byte message cannot be decoded. +/// Returns `PecosError` if the byte message cannot be decoded or contains a +/// gate unsupported by the pecos-neo command representation. #[cfg(feature = "engines-adapter")] pub fn byte_message_to_command_queue( message: &pecos_engines::ByteMessage, @@ -163,9 +173,7 @@ pub fn byte_message_to_command_queue( let mut queue = CommandQueue::with_capacity(gates.len()); for gate in &gates { - if let Some(cmd) = convert_gate(gate) { - queue.push(cmd); - } + queue.push(convert_gate(gate)?); } Ok(queue) @@ -356,21 +364,27 @@ where /// /// This is useful when you have raw Gate objects and want to convert them /// to the pecos-neo format. -#[must_use] -pub fn gate_to_command(gate: &Gate) -> Option { +/// +/// # Errors +/// +/// Returns `PecosError` if the gate has no pecos-neo command representation. +pub fn gate_to_command(gate: &Gate) -> Result { convert_gate(gate) } /// Convert a slice of pecos-core Gates to a `CommandQueue`. -#[must_use] -pub fn gates_to_command_queue(gates: &[Gate]) -> CommandQueue { +/// +/// # Errors +/// +/// Returns `PecosError` if any gate has no pecos-neo command representation. +pub fn gates_to_command_queue( + gates: &[Gate], +) -> Result { let mut queue = CommandQueue::with_capacity(gates.len()); for gate in gates { - if let Some(cmd) = convert_gate(gate) { - queue.push(cmd); - } + queue.push(convert_gate(gate)?); } - queue + Ok(queue) } /// Convert a `CommandQueue` back to a Vec of pecos-core Gates. @@ -398,6 +412,8 @@ fn convert_neo_to_core_gate_type(neo_type: NeoGateType) -> CoreGateType { NeoGateType::Y => CoreGateType::Y, NeoGateType::Z => CoreGateType::Z, NeoGateType::H => CoreGateType::H, + NeoGateType::F => CoreGateType::F, + NeoGateType::Fdg => CoreGateType::Fdg, NeoGateType::SX => CoreGateType::SX, NeoGateType::SXdg => CoreGateType::SXdg, NeoGateType::SY => CoreGateType::SY, @@ -416,6 +432,10 @@ fn convert_neo_to_core_gate_type(neo_type: NeoGateType) -> CoreGateType { NeoGateType::CZ => CoreGateType::CZ, NeoGateType::SZZ => CoreGateType::SZZ, NeoGateType::SZZdg => CoreGateType::SZZdg, + NeoGateType::SXX => CoreGateType::SXX, + NeoGateType::SXXdg => CoreGateType::SXXdg, + NeoGateType::SYY => CoreGateType::SYY, + NeoGateType::SYYdg => CoreGateType::SYYdg, NeoGateType::SWAP => CoreGateType::SWAP, NeoGateType::CRZ => CoreGateType::CRZ, NeoGateType::RXX => CoreGateType::RXX, @@ -493,7 +513,7 @@ mod tests { Gate::new(CoreGateType::MZ, vec![], vec![], vec![QubitId(0)]), ]; - let queue = gates_to_command_queue(&gates); + let queue = gates_to_command_queue(&gates).expect("should convert"); assert_eq!(queue.len(), 3); } @@ -509,11 +529,23 @@ mod tests { ), ]; - let queue = gates_to_command_queue(&original_gates); + let queue = gates_to_command_queue(&original_gates).expect("should convert"); let back = command_queue_to_gates(&queue); assert_eq!(back.len(), 2); assert_eq!(back[0].gate_type, CoreGateType::H); assert_eq!(back[1].gate_type, CoreGateType::CX); } + + #[test] + fn test_gate_to_command_rejects_channel_gate() { + let gate = Gate::channel(pecos_core::channel::Depolarizing(0.01, 0)); + + let err = gate_to_command(&gate).expect_err("channel gates need typed channel handling"); + + assert!( + err.to_string() + .contains("does not support gate type Channel") + ); + } } diff --git a/exp/pecos-neo/src/circuit.rs b/exp/pecos-neo/src/circuit.rs index a445bd23e..d2911b78c 100644 --- a/exp/pecos-neo/src/circuit.rs +++ b/exp/pecos-neo/src/circuit.rs @@ -46,7 +46,6 @@ use smallvec::SmallVec; // ============================================================================ impl From for GateType { - #[allow(clippy::match_same_arms)] // Unknown gate types explicitly map to I fn from(gt: pecos_core::gate_type::GateType) -> Self { use pecos_core::gate_type::GateType as CoreGT; match gt { @@ -55,6 +54,8 @@ impl From for GateType { CoreGT::Y => Self::Y, CoreGT::Z => Self::Z, CoreGT::H => Self::H, + CoreGT::F => Self::F, + CoreGT::Fdg => Self::Fdg, CoreGT::SX => Self::SX, CoreGT::SXdg => Self::SXdg, CoreGT::SY => Self::SY, @@ -71,6 +72,10 @@ impl From for GateType { CoreGT::CX => Self::CX, CoreGT::CY => Self::CY, CoreGT::CZ => Self::CZ, + CoreGT::SXX => Self::SXX, + CoreGT::SXXdg => Self::SXXdg, + CoreGT::SYY => Self::SYY, + CoreGT::SYYdg => Self::SYYdg, CoreGT::SZZ => Self::SZZ, CoreGT::SZZdg => Self::SZZdg, CoreGT::SWAP => Self::SWAP, @@ -86,8 +91,7 @@ impl From for GateType { CoreGT::QAlloc => Self::QAlloc, CoreGT::QFree => Self::QFree, CoreGT::Idle => Self::Idle, - // Any unknown gate types default to identity - _ => Self::I, + other => panic!("unsupported pecos-core gate type for pecos-neo conversion: {other:?}"), } } } @@ -101,6 +105,8 @@ impl From for pecos_core::gate_type::GateType { GateType::Y => CoreGT::Y, GateType::Z => CoreGT::Z, GateType::H => CoreGT::H, + GateType::F => CoreGT::F, + GateType::Fdg => CoreGT::Fdg, GateType::SX => CoreGT::SX, GateType::SXdg => CoreGT::SXdg, GateType::SY => CoreGT::SY, @@ -119,6 +125,10 @@ impl From for pecos_core::gate_type::GateType { GateType::CZ => CoreGT::CZ, GateType::SZZ => CoreGT::SZZ, GateType::SZZdg => CoreGT::SZZdg, + GateType::SXX => CoreGT::SXX, + GateType::SXXdg => CoreGT::SXXdg, + GateType::SYY => CoreGT::SYY, + GateType::SYYdg => CoreGT::SYYdg, GateType::SWAP => CoreGT::SWAP, GateType::CRZ => CoreGT::CRZ, GateType::RXX => CoreGT::RXX, @@ -178,14 +188,14 @@ impl From for GateCommand { impl From<&TickCircuit> for CommandQueue { /// Convert a `TickCircuit` to a `CommandQueue`. /// - /// Gates are added in tick order - all gates from tick 0, then tick 1, etc. - /// Within each tick, gates are added in the order they appear. + /// Gate batches are added in tick order - all commands from tick 0, then tick 1, etc. + /// Within each tick, commands are added in the order they appear. fn from(circuit: &TickCircuit) -> Self { let mut queue = CommandQueue::new(); for tick in circuit.ticks() { - for gate in tick.gates() { - queue.push(gate.into()); + for gate in tick.iter_gate_batches() { + queue.push(gate.as_gate().into()); } } @@ -288,9 +298,11 @@ impl From<&CommandQueue> for TickCircuit { angles, params: SmallVec::new(), qubits: qubit_ids, + meas_ids: SmallVec::new(), + channel: None, }; - // Use try_add_gate and ignore errors (shouldn't happen with one gate per tick) - let _ = tick.try_add_gate(gate); + tick.try_add_gate(gate) + .expect("one gate per tick should not have qubit conflicts"); } } } @@ -419,6 +431,8 @@ mod tests { angles: smallvec::smallvec![Angle64::QUARTER_TURN], params: SmallVec::new(), qubits: smallvec::smallvec![QubitId(0)], + meas_ids: SmallVec::new(), + channel: None, }; let cmd: GateCommand = (&gate).into(); diff --git a/exp/pecos-neo/src/command.rs b/exp/pecos-neo/src/command.rs index b42bae781..a26c8b3a4 100644 --- a/exp/pecos-neo/src/command.rs +++ b/exp/pecos-neo/src/command.rs @@ -37,6 +37,8 @@ pub enum GateType { // Single-qubit Cliffords H, + F, + Fdg, SX, SXdg, SY, @@ -59,6 +61,10 @@ pub enum GateType { CZ, SZZ, SZZdg, + SXX, + SXXdg, + SYY, + SYYdg, SWAP, CRZ, RXX, @@ -90,6 +96,8 @@ impl GateType { | Self::Y | Self::Z | Self::H + | Self::F + | Self::Fdg | Self::SX | Self::SXdg | Self::SY @@ -116,6 +124,10 @@ impl GateType { | Self::CZ | Self::SZZ | Self::SZZdg + | Self::SXX + | Self::SXXdg + | Self::SYY + | Self::SYYdg | Self::SWAP | Self::CRZ | Self::RXX @@ -160,6 +172,31 @@ impl GateType { pub const fn is_preparation(self) -> bool { matches!(self, Self::PZ | Self::QAlloc) } + + /// Returns true if this is an idle operation. + #[must_use] + pub const fn is_idle(self) -> bool { + matches!(self, Self::Idle) + } + + /// Returns true if this is a resource management operation. + #[must_use] + pub const fn is_resource_management(self) -> bool { + matches!(self, Self::QAlloc | Self::QFree) + } + + /// Returns true if this is a unitary gate (not preparation, measurement, + /// idle, or resource management). + /// + /// These are the gates that should receive gate depolarizing noise + /// (p1 for single-qubit, p2 for two-qubit). + #[must_use] + pub const fn is_unitary_gate(self) -> bool { + !self.is_measurement() + && !self.is_preparation() + && !self.is_idle() + && !self.is_resource_management() + } } /// A single quantum gate command. diff --git a/exp/pecos-neo/src/command/builder.rs b/exp/pecos-neo/src/command/builder.rs index 4a4ece180..aafe37175 100644 --- a/exp/pecos-neo/src/command/builder.rs +++ b/exp/pecos-neo/src/command/builder.rs @@ -136,6 +136,28 @@ impl CommandBuilder { self } + /// Add face gates. + #[must_use] + pub fn f(mut self, qubits: &[impl Into + Copy]) -> Self { + for &q in qubits { + self.queue + .push(GateCommand::new(GateType::F, smallvec::smallvec![q.into()])); + } + self + } + + /// Add face-dagger gates. + #[must_use] + pub fn fdg(mut self, qubits: &[impl Into + Copy]) -> Self { + for &q in qubits { + self.queue.push(GateCommand::new( + GateType::Fdg, + smallvec::smallvec![q.into()], + )); + } + self + } + /// Add SX (sqrt-X) gates. #[must_use] pub fn sx(mut self, qubits: &[impl Into + Copy]) -> Self { @@ -276,6 +298,24 @@ impl CommandBuilder { self } + /// Add R1XY rotation gates with two angles (theta, phi). + #[must_use] + pub fn r1xy( + mut self, + qubits: &[impl Into + Copy], + theta: impl Into + Copy, + phi: impl Into + Copy, + ) -> Self { + for &q in qubits { + self.queue.push(GateCommand::with_angles( + GateType::R1XY, + smallvec::smallvec![q.into()], + smallvec::smallvec![theta.into(), phi.into()], + )); + } + self + } + // Two-qubit gates /// Add CNOT (CX) gates. @@ -321,6 +361,75 @@ impl CommandBuilder { self } + /// Add SXX gates. + #[must_use] + pub fn sxx(mut self, pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + for &(q0, q1) in pairs { + self.queue.push(GateCommand::new( + GateType::SXX, + smallvec::smallvec![q0.into(), q1.into()], + )); + } + self + } + + /// Add `SXXdg` gates. + #[must_use] + pub fn sxxdg( + mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> Self { + for &(q0, q1) in pairs { + self.queue.push(GateCommand::new( + GateType::SXXdg, + smallvec::smallvec![q0.into(), q1.into()], + )); + } + self + } + + /// Add SYY gates. + #[must_use] + pub fn syy(mut self, pairs: &[(impl Into + Copy, impl Into + Copy)]) -> Self { + for &(q0, q1) in pairs { + self.queue.push(GateCommand::new( + GateType::SYY, + smallvec::smallvec![q0.into(), q1.into()], + )); + } + self + } + + /// Add `SYYdg` gates. + #[must_use] + pub fn syydg( + mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> Self { + for &(q0, q1) in pairs { + self.queue.push(GateCommand::new( + GateType::SYYdg, + smallvec::smallvec![q0.into(), q1.into()], + )); + } + self + } + + /// Add `SZZdg` gates (inverse of `SZZ`). + #[must_use] + pub fn szzdg( + mut self, + pairs: &[(impl Into + Copy, impl Into + Copy)], + ) -> Self { + for &(q0, q1) in pairs { + self.queue.push(GateCommand::new( + GateType::SZZdg, + smallvec::smallvec![q0.into(), q1.into()], + )); + } + self + } + /// Add SWAP gates. #[must_use] pub fn swap( diff --git a/exp/pecos-neo/src/engines.rs b/exp/pecos-neo/src/engines.rs index 5bff6aa35..4c482a07b 100644 --- a/exp/pecos-neo/src/engines.rs +++ b/exp/pecos-neo/src/engines.rs @@ -138,8 +138,8 @@ impl CommandSource for TickCircuitEngine { self.current_tick += 1; let mut queue = CommandQueue::new(); - for gate in tick.gates() { - queue.push(gate.into()); + for gate in tick.iter_gate_batches() { + queue.push(gate.as_gate().into()); } Some(queue) diff --git a/exp/pecos-neo/src/extensible/bridge.rs b/exp/pecos-neo/src/extensible/bridge.rs index 0e9549674..ca367a006 100644 --- a/exp/pecos-neo/src/extensible/bridge.rs +++ b/exp/pecos-neo/src/extensible/bridge.rs @@ -21,6 +21,8 @@ impl GateType { // Single-qubit Cliffords Self::H => gates::H, + Self::F => gates::F, + Self::Fdg => gates::Fdg, Self::SX => gates::SX, Self::SXdg => gates::SXdg, Self::SY => gates::SY, @@ -43,6 +45,10 @@ impl GateType { Self::CZ => gates::CZ, Self::SZZ => gates::SZZ, Self::SZZdg => gates::SZZdg, + Self::SXX => gates::SXX, + Self::SXXdg => gates::SXXdg, + Self::SYY => gates::SYY, + Self::SYYdg => gates::SYYdg, Self::SWAP => gates::SWAP, Self::CRZ => gates::CRZ, Self::RXX => gates::RXX, @@ -94,6 +100,8 @@ impl GateId { 14 => GateType::SYdg, 15 => GateType::SZ, 16 => GateType::SZdg, + 17 => GateType::F, + 18 => GateType::Fdg, // T gates 20 => GateType::T, @@ -113,6 +121,10 @@ impl GateId { 53 => GateType::SWAP, // Two-qubit Clifford rotations + 60 => GateType::SXX, + 61 => GateType::SXXdg, + 62 => GateType::SYY, + 63 => GateType::SYYdg, 64 => GateType::SZZ, 65 => GateType::SZZdg, @@ -202,6 +214,8 @@ mod tests { GateType::Y, GateType::Z, GateType::H, + GateType::F, + GateType::Fdg, GateType::SX, GateType::SXdg, GateType::SY, @@ -220,6 +234,10 @@ mod tests { GateType::CZ, GateType::SZZ, GateType::SZZdg, + GateType::SXX, + GateType::SXXdg, + GateType::SYY, + GateType::SYYdg, GateType::SWAP, GateType::CRZ, GateType::RXX, @@ -286,6 +304,8 @@ mod tests { assert_eq!(GateType::I.to_gate_id(), gates::I); assert_eq!(GateType::X.to_gate_id(), gates::X); assert_eq!(GateType::H.to_gate_id(), gates::H); + assert_eq!(GateType::F.to_gate_id(), gates::F); + assert_eq!(GateType::Fdg.to_gate_id(), gates::Fdg); assert_eq!(GateType::RZ.to_gate_id(), gates::RZ); assert_eq!(GateType::CX.to_gate_id(), gates::CX); assert_eq!(GateType::CCX.to_gate_id(), gates::CCX); diff --git a/exp/pecos-neo/src/extensible/definitions.rs b/exp/pecos-neo/src/extensible/definitions.rs index c1ed5cd6d..ef36b882b 100644 --- a/exp/pecos-neo/src/extensible/definitions.rs +++ b/exp/pecos-neo/src/extensible/definitions.rs @@ -325,6 +325,14 @@ impl GateDefinitions { gates::H, GateSpec::new("H").with_category(GateCategory::SingleQubitUnitary), ); + self.set_core_spec( + gates::F, + GateSpec::new("F").with_category(GateCategory::SingleQubitUnitary), + ); + self.set_core_spec( + gates::Fdg, + GateSpec::new("Fdg").with_category(GateCategory::SingleQubitUnitary), + ); self.set_core_spec( gates::SX, GateSpec::new("SX").with_category(GateCategory::SingleQubitUnitary), @@ -417,6 +425,42 @@ impl GateDefinitions { .with_quantum_arity(2) .with_category(GateCategory::TwoQubitUnitary), ); + self.set_core_spec( + gates::SXX, + GateSpec::new("SXX") + .with_quantum_arity(2) + .with_category(GateCategory::TwoQubitUnitary), + ); + self.set_core_spec( + gates::SXXdg, + GateSpec::new("SXXdg") + .with_quantum_arity(2) + .with_category(GateCategory::TwoQubitUnitary), + ); + self.set_core_spec( + gates::SYY, + GateSpec::new("SYY") + .with_quantum_arity(2) + .with_category(GateCategory::TwoQubitUnitary), + ); + self.set_core_spec( + gates::SYYdg, + GateSpec::new("SYYdg") + .with_quantum_arity(2) + .with_category(GateCategory::TwoQubitUnitary), + ); + self.set_core_spec( + gates::SZZ, + GateSpec::new("SZZ") + .with_quantum_arity(2) + .with_category(GateCategory::TwoQubitUnitary), + ); + self.set_core_spec( + gates::SZZdg, + GateSpec::new("SZZdg") + .with_quantum_arity(2) + .with_category(GateCategory::TwoQubitUnitary), + ); // Two-qubit parameterized self.set_core_spec( diff --git a/exp/pecos-neo/src/extensible/gate_id.rs b/exp/pecos-neo/src/extensible/gate_id.rs index 73d9eed31..d6472d3c0 100644 --- a/exp/pecos-neo/src/extensible/gate_id.rs +++ b/exp/pecos-neo/src/extensible/gate_id.rs @@ -67,6 +67,8 @@ pub mod gates { pub const SYdg: GateId = GateId(14); pub const SZ: GateId = GateId(15); pub const SZdg: GateId = GateId(16); + pub const F: GateId = GateId(17); + pub const Fdg: GateId = GateId(18); // T gates pub const T: GateId = GateId(20); diff --git a/exp/pecos-neo/src/extensible/queue_validation.rs b/exp/pecos-neo/src/extensible/queue_validation.rs index a0c8737a3..daf559bee 100644 --- a/exp/pecos-neo/src/extensible/queue_validation.rs +++ b/exp/pecos-neo/src/extensible/queue_validation.rs @@ -149,6 +149,8 @@ pub fn is_clifford_gate_type(gate_type: GateType) -> bool { | GateType::Y | GateType::Z | GateType::H + | GateType::F + | GateType::Fdg | GateType::SX | GateType::SXdg | GateType::SY @@ -160,6 +162,10 @@ pub fn is_clifford_gate_type(gate_type: GateType) -> bool { | GateType::CZ | GateType::SZZ | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg | GateType::SWAP | GateType::MZ | GateType::MeasureLeaked diff --git a/exp/pecos-neo/src/extensible/registry.rs b/exp/pecos-neo/src/extensible/registry.rs index 946b6a916..d47d52f95 100644 --- a/exp/pecos-neo/src/extensible/registry.rs +++ b/exp/pecos-neo/src/extensible/registry.rs @@ -62,6 +62,14 @@ impl GateRegistry { gates::H, &GateSpec::new("H").with_category(GateCategory::SingleQubitUnitary), ); + self.set_core( + gates::F, + &GateSpec::new("F").with_category(GateCategory::SingleQubitUnitary), + ); + self.set_core( + gates::Fdg, + &GateSpec::new("Fdg").with_category(GateCategory::SingleQubitUnitary), + ); self.set_core( gates::SX, &GateSpec::new("SX").with_category(GateCategory::SingleQubitUnitary), diff --git a/exp/pecos-neo/src/extensible/tests.rs b/exp/pecos-neo/src/extensible/tests.rs index 1728ffa1d..5ba2e2bb5 100644 --- a/exp/pecos-neo/src/extensible/tests.rs +++ b/exp/pecos-neo/src/extensible/tests.rs @@ -3,8 +3,225 @@ use super::validator::GateForValidation; use super::*; +use crate::command::{CommandBuilder, GateType as CommandGateType}; use pecos_core::{Angle64, QubitId}; +#[derive(Debug, Clone, Copy)] +struct StandardCliffordCase { + gate_type: CommandGateType, + gate_id: GateId, + name: &'static str, + arity: u8, + inverse: CommandGateType, +} + +fn standard_clifford_cases() -> &'static [StandardCliffordCase] { + use CommandGateType::{ + CX, CY, CZ, F, Fdg, H, I, SWAP, SX, SXX, SXXdg, SXdg, SY, SYY, SYYdg, SYdg, SZ, SZZ, SZZdg, + SZdg, X, Y, Z, + }; + + &[ + StandardCliffordCase { + gate_type: I, + gate_id: gates::I, + name: "I", + arity: 1, + inverse: I, + }, + StandardCliffordCase { + gate_type: X, + gate_id: gates::X, + name: "X", + arity: 1, + inverse: X, + }, + StandardCliffordCase { + gate_type: Y, + gate_id: gates::Y, + name: "Y", + arity: 1, + inverse: Y, + }, + StandardCliffordCase { + gate_type: Z, + gate_id: gates::Z, + name: "Z", + arity: 1, + inverse: Z, + }, + StandardCliffordCase { + gate_type: H, + gate_id: gates::H, + name: "H", + arity: 1, + inverse: H, + }, + StandardCliffordCase { + gate_type: F, + gate_id: gates::F, + name: "F", + arity: 1, + inverse: Fdg, + }, + StandardCliffordCase { + gate_type: Fdg, + gate_id: gates::Fdg, + name: "Fdg", + arity: 1, + inverse: F, + }, + StandardCliffordCase { + gate_type: SX, + gate_id: gates::SX, + name: "SX", + arity: 1, + inverse: SXdg, + }, + StandardCliffordCase { + gate_type: SXdg, + gate_id: gates::SXdg, + name: "SXdg", + arity: 1, + inverse: SX, + }, + StandardCliffordCase { + gate_type: SY, + gate_id: gates::SY, + name: "SY", + arity: 1, + inverse: SYdg, + }, + StandardCliffordCase { + gate_type: SYdg, + gate_id: gates::SYdg, + name: "SYdg", + arity: 1, + inverse: SY, + }, + StandardCliffordCase { + gate_type: SZ, + gate_id: gates::SZ, + name: "SZ", + arity: 1, + inverse: SZdg, + }, + StandardCliffordCase { + gate_type: SZdg, + gate_id: gates::SZdg, + name: "SZdg", + arity: 1, + inverse: SZ, + }, + StandardCliffordCase { + gate_type: CX, + gate_id: gates::CX, + name: "CX", + arity: 2, + inverse: CX, + }, + StandardCliffordCase { + gate_type: CY, + gate_id: gates::CY, + name: "CY", + arity: 2, + inverse: CY, + }, + StandardCliffordCase { + gate_type: CZ, + gate_id: gates::CZ, + name: "CZ", + arity: 2, + inverse: CZ, + }, + StandardCliffordCase { + gate_type: SXX, + gate_id: gates::SXX, + name: "SXX", + arity: 2, + inverse: SXXdg, + }, + StandardCliffordCase { + gate_type: SXXdg, + gate_id: gates::SXXdg, + name: "SXXdg", + arity: 2, + inverse: SXX, + }, + StandardCliffordCase { + gate_type: SYY, + gate_id: gates::SYY, + name: "SYY", + arity: 2, + inverse: SYYdg, + }, + StandardCliffordCase { + gate_type: SYYdg, + gate_id: gates::SYYdg, + name: "SYYdg", + arity: 2, + inverse: SYY, + }, + StandardCliffordCase { + gate_type: SZZ, + gate_id: gates::SZZ, + name: "SZZ", + arity: 2, + inverse: SZZdg, + }, + StandardCliffordCase { + gate_type: SZZdg, + gate_id: gates::SZZdg, + name: "SZZdg", + arity: 2, + inverse: SZZ, + }, + StandardCliffordCase { + gate_type: SWAP, + gate_id: gates::SWAP, + name: "SWAP", + arity: 2, + inverse: SWAP, + }, + ] +} + +fn command_from_builder(gate_type: CommandGateType) -> crate::command::GateCommand { + let queue = match gate_type { + CommandGateType::I => CommandBuilder::new().identity(&[0]).build(), + CommandGateType::X => CommandBuilder::new().x(&[0]).build(), + CommandGateType::Y => CommandBuilder::new().y(&[0]).build(), + CommandGateType::Z => CommandBuilder::new().z(&[0]).build(), + CommandGateType::H => CommandBuilder::new().h(&[0]).build(), + CommandGateType::F => CommandBuilder::new().f(&[0]).build(), + CommandGateType::Fdg => CommandBuilder::new().fdg(&[0]).build(), + CommandGateType::SX => CommandBuilder::new().sx(&[0]).build(), + CommandGateType::SXdg => CommandBuilder::new().sxdg(&[0]).build(), + CommandGateType::SY => CommandBuilder::new().sy(&[0]).build(), + CommandGateType::SYdg => CommandBuilder::new().sydg(&[0]).build(), + CommandGateType::SZ => CommandBuilder::new().sz(&[0]).build(), + CommandGateType::SZdg => CommandBuilder::new().szdg(&[0]).build(), + CommandGateType::CX => CommandBuilder::new().cx(&[(0, 1)]).build(), + CommandGateType::CY => CommandBuilder::new().cy(&[(0, 1)]).build(), + CommandGateType::CZ => CommandBuilder::new().cz(&[(0, 1)]).build(), + CommandGateType::SXX => CommandBuilder::new().sxx(&[(0, 1)]).build(), + CommandGateType::SXXdg => CommandBuilder::new().sxxdg(&[(0, 1)]).build(), + CommandGateType::SYY => CommandBuilder::new().syy(&[(0, 1)]).build(), + CommandGateType::SYYdg => CommandBuilder::new().syydg(&[(0, 1)]).build(), + CommandGateType::SZZ => CommandBuilder::new().szz(&[(0, 1)]).build(), + CommandGateType::SZZdg => CommandBuilder::new().szzdg(&[(0, 1)]).build(), + CommandGateType::SWAP => CommandBuilder::new().swap(&[(0, 1)]).build(), + other => panic!("not a standard Clifford conformance gate: {other:?}"), + }; + + assert_eq!( + queue.len(), + 1, + "{gate_type:?} builder should emit one command" + ); + queue.iter().next().unwrap().clone() +} + // ============================================================================ // GateId Tests // ============================================================================ @@ -1266,6 +1483,78 @@ fn test_validator_is_gate_allowed() { assert!(!validator.is_gate_allowed(gates::RZ, &[Angle64::from_turns(0.1)], ®istry)); } +#[test] +fn test_standard_cliffords_are_registered_defined_and_allowed() { + let registry = GateRegistry::new(); + let definitions = GateDefinitions::new(); + let validator = CliffordValidator::new(); + + for case in standard_clifford_cases() { + assert!( + registry.get(case.gate_id).is_some(), + "missing registry spec for {}", + case.name + ); + assert!( + definitions.spec(case.gate_id).is_some(), + "missing gate definition for {}", + case.name + ); + assert!( + validator.is_gate_allowed(case.gate_id, &[], ®istry), + "Clifford validator should allow {}", + case.name + ); + } +} + +#[test] +fn test_standard_clifford_gate_surface_is_consistent() { + let registry = GateRegistry::new(); + let definitions = GateDefinitions::new(); + + for case in standard_clifford_cases() { + let registry_spec = registry.get(case.gate_id).unwrap(); + let definition_spec = definitions.spec(case.gate_id).unwrap(); + assert_eq!(registry_spec.name, case.name, "{case:?}"); + assert_eq!(definition_spec.name, case.name, "{case:?}"); + assert_eq!(registry_spec.quantum_arity, case.arity, "{case:?}"); + assert_eq!(definition_spec.quantum_arity, case.arity, "{case:?}"); + assert_eq!( + case.gate_type.quantum_arity(), + case.arity as usize, + "{case:?}" + ); + assert_eq!(case.gate_type.angle_arity(), 0, "{case:?}"); + assert_eq!(case.gate_type.to_gate_id(), case.gate_id, "{case:?}"); + assert_eq!( + case.gate_id.try_to_gate_type(), + Some(case.gate_type), + "{case:?}" + ); + + let command = command_from_builder(case.gate_type); + assert_eq!(command.gate_type, case.gate_type, "{case:?}"); + assert_eq!(command.qubits.len(), case.arity as usize, "{case:?}"); + assert!(command.angles.is_empty(), "{case:?}"); + } +} + +#[test] +fn test_standard_clifford_inverse_table_is_symmetric() { + for case in standard_clifford_cases() { + let inverse = standard_clifford_cases() + .iter() + .find(|candidate| candidate.gate_type == case.inverse) + .unwrap_or_else(|| panic!("missing inverse {:?} for {}", case.inverse, case.name)); + assert_eq!( + inverse.inverse, case.gate_type, + "inverse table should be symmetric for {}", + case.name + ); + } +} + #[test] fn test_validation_error_display() { let err = ValidationError::ForbiddenGate { diff --git a/exp/pecos-neo/src/extensible/validator.rs b/exp/pecos-neo/src/extensible/validator.rs index 7635f4673..758982989 100644 --- a/exp/pecos-neo/src/extensible/validator.rs +++ b/exp/pecos-neo/src/extensible/validator.rs @@ -141,6 +141,8 @@ impl CliffordValidator { // Cliffords allowed_gates.insert(gates::H); + allowed_gates.insert(gates::F); + allowed_gates.insert(gates::Fdg); allowed_gates.insert(gates::SX); allowed_gates.insert(gates::SXdg); allowed_gates.insert(gates::SY); @@ -154,6 +156,10 @@ impl CliffordValidator { allowed_gates.insert(gates::CZ); allowed_gates.insert(gates::SWAP); allowed_gates.insert(gates::ISWAP); + allowed_gates.insert(gates::SXX); + allowed_gates.insert(gates::SXXdg); + allowed_gates.insert(gates::SYY); + allowed_gates.insert(gates::SYYdg); allowed_gates.insert(gates::SZZ); allowed_gates.insert(gates::SZZdg); diff --git a/exp/pecos-neo/src/inline_channel.rs b/exp/pecos-neo/src/inline_channel.rs new file mode 100644 index 000000000..e9f9830d9 --- /dev/null +++ b/exp/pecos-neo/src/inline_channel.rs @@ -0,0 +1,583 @@ +// 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. + +//! Execution helpers for `TickCircuit`s containing inline channel gates. +//! +//! These routines consume the concrete channel operations inserted into a +//! `TickCircuit`, rather than adding a separate event-driven noise model at +//! execution time. + +use pecos_core::gate_type::GateType; +use pecos_core::{Angle64, ChannelExpr, Gate, Pauli, PauliOperator, PauliString, QubitId}; +use pecos_quantum::TickCircuit; +use pecos_random::{PecosRng, RngExt}; +use pecos_simulators::{ArbitraryRotationGateable, CliffordGateable, DensityMatrix, SparseStab}; +use thiserror::Error; + +const PROBABILITY_SUM_TOLERANCE: f64 = 1e-9; + +/// Error returned while executing a circuit with inline channel gates. +#[derive(Debug, Error)] +pub enum InlineChannelError { + /// A `Channel` gate had no channel payload. + #[error("Channel gate missing channel payload")] + MissingChannelPayload, + + /// A two-qubit gate was given an odd number of qubits. + #[error("{gate_type:?} requires an even number of qubits, got {qubit_count}")] + InvalidPairArity { + gate_type: GateType, + qubit_count: usize, + }, + + /// A parameterized gate was missing one of its angle parameters. + #[error("{gate_type:?} is missing angle parameter {index}")] + MissingAngle { gate_type: GateType, index: usize }, + + /// Density-matrix execution does not support this gate on the inline path. + #[error("DensityMatrix inline-channel path does not support gate {gate_type:?}")] + UnsupportedDensityMatrixGate { gate_type: GateType }, + + /// Stabilizer execution does not support this gate on the inline path. + #[error("stabilizer inline-channel path does not support non-Clifford gate {gate_type:?}")] + UnsupportedStabilizerGate { gate_type: GateType }, + + /// The stabilizer backend was asked to sample a non-Pauli channel. + #[error("stabilizer backend can only sample inline Pauli channels")] + NonPauliChannel, + + /// The stabilizer backend was asked to sample a channel that is not a Pauli mixed-unitary. + #[error("stabilizer backend can only sample inline Pauli mixed-unitary channels")] + NonPauliMixedUnitaryChannel, + + /// A mixed-unitary channel had invalid probabilities. + #[error("channel probabilities must be non-negative and sum to 1")] + InvalidProbabilities, + + /// Probability validation passed, but random sampling still missed every term. + #[error("Pauli channel sampling failed after probability validation")] + SamplingMiss, + + /// `DensityMatrix` rejected a channel expression. + #[error("channel application failed: {0}")] + ChannelApplication(String), +} + +/// Return the number of qubits needed to simulate the circuit. +#[must_use] +pub fn tick_circuit_num_qubits(circuit: &TickCircuit) -> usize { + circuit + .all_qubits() + .into_iter() + .map(|q| q.index() + 1) + .max() + .unwrap_or(0) +} + +/// Execute an inline-channel `TickCircuit` using a density matrix simulator. +/// +/// This path supports arbitrary channel expressions accepted by +/// [`DensityMatrix::apply_channel_expr`], and also supports the standard +/// unitary gates implemented by the density matrix simulator. +/// +/// # Errors +/// +/// Returns [`InlineChannelError`] when a channel payload is malformed, when a +/// gate has invalid arity or missing parameters, or when the density matrix +/// backend does not support a gate/channel. +pub fn run_inline_channels_density_matrix( + circuit: &TickCircuit, + shots: usize, + seed: u64, +) -> Result>, InlineChannelError> { + let num_qubits = tick_circuit_num_qubits(circuit); + let mut rows = Vec::with_capacity(shots); + + for shot in 0..shots { + let shot_seed = seed.wrapping_add(shot as u64); + let mut sim = DensityMatrix::with_seed(num_qubits, shot_seed); + let mut row = Vec::new(); + + for tick in circuit.ticks() { + for gate in tick.iter_gate_batches() { + row.extend(apply_gate_to_density_matrix(&mut sim, gate.as_gate())?); + } + } + + rows.push(row); + } + + Ok(rows) +} + +/// Execute an inline-channel `TickCircuit` using a stabilizer simulator. +/// +/// This path supports Clifford gates plus channel gates whose payloads are +/// Pauli or Pauli mixed-unitary channels. Non-Pauli channels are rejected +/// explicitly. +/// +/// # Errors +/// +/// Returns [`InlineChannelError`] for unsupported gates, malformed channel +/// probabilities, non-Pauli channels, invalid arity, or missing gate +/// parameters. +pub fn run_inline_pauli_channels_stabilizer( + circuit: &TickCircuit, + shots: usize, + seed: u64, +) -> Result>, InlineChannelError> { + let num_qubits = tick_circuit_num_qubits(circuit); + let mut rows = Vec::with_capacity(shots); + + for shot in 0..shots { + let shot_seed = seed.wrapping_add(shot as u64); + let mut sim = SparseStab::with_seed(num_qubits, shot_seed); + let mut rng = PecosRng::seed_from_u64(shot_seed ^ 0x5eed_5eed_5eed_5eed); + let mut row = Vec::new(); + + for tick in circuit.ticks() { + for gate in tick.iter_gate_batches() { + row.extend(apply_gate_to_stabilizer_with_pauli_channels( + &mut sim, + gate.as_gate(), + &mut rng, + )?); + } + } + + rows.push(row); + } + + Ok(rows) +} + +fn qubit_pairs( + qubits: &[QubitId], + gate_type: GateType, +) -> Result, InlineChannelError> { + if !qubits.len().is_multiple_of(2) { + return Err(InlineChannelError::InvalidPairArity { + gate_type, + qubit_count: qubits.len(), + }); + } + Ok(qubits + .chunks_exact(2) + .map(|pair| (pair[0], pair[1])) + .collect()) +} + +fn gate_angle(gate: &Gate, index: usize) -> Result { + gate.angles + .get(index) + .copied() + .ok_or(InlineChannelError::MissingAngle { + gate_type: gate.gate_type, + index, + }) +} + +fn apply_gate_to_density_matrix( + sim: &mut DensityMatrix, + gate: &Gate, +) -> Result, InlineChannelError> { + let qubits = gate.qubits.as_slice(); + match gate.gate_type { + GateType::Channel => { + let channel = gate + .channel_expr() + .ok_or(InlineChannelError::MissingChannelPayload)?; + sim.apply_channel_expr(channel) + .map_err(|e| InlineChannelError::ChannelApplication(e.to_string()))?; + Ok(Vec::new()) + } + GateType::PZ | GateType::QAlloc => { + sim.pz(qubits); + Ok(Vec::new()) + } + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked => Ok(sim + .mz(qubits) + .into_iter() + .map(|r| u8::from(r.outcome)) + .collect()), + GateType::I | GateType::Idle => Ok(Vec::new()), + GateType::X => { + sim.x(qubits); + Ok(Vec::new()) + } + GateType::Y => { + sim.y(qubits); + Ok(Vec::new()) + } + GateType::Z => { + sim.z(qubits); + Ok(Vec::new()) + } + GateType::H => { + sim.h(qubits); + Ok(Vec::new()) + } + GateType::F => { + sim.f(qubits); + Ok(Vec::new()) + } + GateType::Fdg => { + sim.fdg(qubits); + Ok(Vec::new()) + } + GateType::SX => { + sim.sx(qubits); + Ok(Vec::new()) + } + GateType::SXdg => { + sim.sxdg(qubits); + Ok(Vec::new()) + } + GateType::SY => { + sim.sy(qubits); + Ok(Vec::new()) + } + GateType::SYdg => { + sim.sydg(qubits); + Ok(Vec::new()) + } + GateType::SZ => { + sim.sz(qubits); + Ok(Vec::new()) + } + GateType::SZdg => { + sim.szdg(qubits); + Ok(Vec::new()) + } + GateType::CX => { + sim.cx(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::CY => { + sim.cy(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::CZ => { + sim.cz(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SXX => { + sim.sxx(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SXXdg => { + sim.sxxdg(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SYY => { + sim.syy(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SYYdg => { + sim.syydg(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SZZ => { + sim.szz(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SZZdg => { + sim.szzdg(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SWAP => { + sim.swap(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::T => { + sim.t(qubits); + Ok(Vec::new()) + } + GateType::Tdg => { + sim.tdg(qubits); + Ok(Vec::new()) + } + GateType::RX => { + sim.rx(gate_angle(gate, 0)?, qubits); + Ok(Vec::new()) + } + GateType::RY => { + sim.ry(gate_angle(gate, 0)?, qubits); + Ok(Vec::new()) + } + GateType::RZ => { + sim.rz(gate_angle(gate, 0)?, qubits); + Ok(Vec::new()) + } + GateType::U => { + sim.u( + gate_angle(gate, 0)?, + gate_angle(gate, 1)?, + gate_angle(gate, 2)?, + qubits, + ); + Ok(Vec::new()) + } + GateType::R1XY => { + sim.r1xy(gate_angle(gate, 0)?, gate_angle(gate, 1)?, qubits); + Ok(Vec::new()) + } + GateType::RXX => { + sim.rxx(gate_angle(gate, 0)?, &qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::RYY => { + sim.ryy(gate_angle(gate, 0)?, &qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::RZZ => { + sim.rzz(gate_angle(gate, 0)?, &qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + gate_type => Err(InlineChannelError::UnsupportedDensityMatrixGate { gate_type }), + } +} + +fn apply_pauli_string_to_stabilizer(sim: &mut SparseStab, pauli: &PauliString) { + for (p, q) in pauli.paulis() { + match p { + Pauli::I => {} + Pauli::X => { + sim.x(&[*q]); + } + Pauli::Y => { + sim.y(&[*q]); + } + Pauli::Z => { + sim.z(&[*q]); + } + } + } +} + +fn sample_pauli_channel( + channel: &ChannelExpr, + rng: &mut PecosRng, +) -> Result, InlineChannelError> { + let ops = match channel { + ChannelExpr::Unitary(unitary) => { + let pauli = unitary + .clone() + .try_to_pauli_string() + .ok_or(InlineChannelError::NonPauliChannel)?; + return Ok((pauli.weight() > 0).then_some(pauli)); + } + ChannelExpr::MixedUnitary(ops) => ops, + _ => return Err(InlineChannelError::NonPauliMixedUnitaryChannel), + }; + + let total: f64 = ops.iter().map(|(p, _)| *p).sum(); + if (total - 1.0).abs() > PROBABILITY_SUM_TOLERANCE || ops.iter().any(|(p, _)| *p < 0.0) { + return Err(InlineChannelError::InvalidProbabilities); + } + + let mut threshold = rng.random::() * total; + for (prob, unitary) in ops { + if threshold < *prob { + let pauli = unitary + .clone() + .try_to_pauli_string() + .ok_or(InlineChannelError::NonPauliChannel)?; + return Ok((pauli.weight() > 0).then_some(pauli)); + } + threshold -= *prob; + } + + Err(InlineChannelError::SamplingMiss) +} + +fn apply_gate_to_stabilizer_with_pauli_channels( + sim: &mut SparseStab, + gate: &Gate, + rng: &mut PecosRng, +) -> Result, InlineChannelError> { + let qubits = gate.qubits.as_slice(); + match gate.gate_type { + GateType::Channel => { + let channel = gate + .channel_expr() + .ok_or(InlineChannelError::MissingChannelPayload)?; + if let Some(pauli) = sample_pauli_channel(channel, rng)? { + apply_pauli_string_to_stabilizer(sim, &pauli); + } + Ok(Vec::new()) + } + GateType::PZ | GateType::QAlloc => { + sim.pz(qubits); + Ok(Vec::new()) + } + GateType::MZ | GateType::MeasureFree | GateType::MeasureLeaked => Ok(sim + .mz(qubits) + .into_iter() + .map(|r| u8::from(r.outcome)) + .collect()), + GateType::I | GateType::Idle => Ok(Vec::new()), + GateType::X => { + sim.x(qubits); + Ok(Vec::new()) + } + GateType::Y => { + sim.y(qubits); + Ok(Vec::new()) + } + GateType::Z => { + sim.z(qubits); + Ok(Vec::new()) + } + GateType::H => { + sim.h(qubits); + Ok(Vec::new()) + } + GateType::F => { + sim.f(qubits); + Ok(Vec::new()) + } + GateType::Fdg => { + sim.fdg(qubits); + Ok(Vec::new()) + } + GateType::SX => { + sim.sx(qubits); + Ok(Vec::new()) + } + GateType::SXdg => { + sim.sxdg(qubits); + Ok(Vec::new()) + } + GateType::SY => { + sim.sy(qubits); + Ok(Vec::new()) + } + GateType::SYdg => { + sim.sydg(qubits); + Ok(Vec::new()) + } + GateType::SZ => { + sim.sz(qubits); + Ok(Vec::new()) + } + GateType::SZdg => { + sim.szdg(qubits); + Ok(Vec::new()) + } + GateType::CX => { + sim.cx(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::CY => { + sim.cy(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::CZ => { + sim.cz(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SXX => { + sim.sxx(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SXXdg => { + sim.sxxdg(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SYY => { + sim.syy(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SYYdg => { + sim.syydg(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SZZ => { + sim.szz(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SZZdg => { + sim.szzdg(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + GateType::SWAP => { + sim.swap(&qubit_pairs(qubits, gate.gate_type)?); + Ok(Vec::new()) + } + gate_type => Err(InlineChannelError::UnsupportedStabilizerGate { gate_type }), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn density_matrix_inline_bit_flip_channel_flips_measurement() { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0]); + circuit.tick().channel(pecos_core::channel::BitFlip(1.0, 0)); + circuit.tick().mz(&[0]); + + let rows = run_inline_channels_density_matrix(&circuit, 3, 123).unwrap(); + + assert_eq!(rows, vec![vec![1], vec![1], vec![1]]); + } + + #[test] + fn stabilizer_inline_pauli_channel_flips_measurement() { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0]); + circuit.tick().channel(pecos_core::channel::BitFlip(1.0, 0)); + circuit.tick().mz(&[0]); + + let rows = run_inline_pauli_channels_stabilizer(&circuit, 3, 123).unwrap(); + + assert_eq!(rows, vec![vec![1], vec![1], vec![1]]); + } + + #[test] + fn stabilizer_inline_channel_rejects_non_clifford_gate() { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0]); + circuit.tick().t(&[0]); + circuit.tick().channel(pecos_core::channel::BitFlip(0.5, 0)); + circuit.tick().mz(&[0]); + + let err = run_inline_pauli_channels_stabilizer(&circuit, 1, 123).unwrap_err(); + + assert!(matches!( + err, + InlineChannelError::UnsupportedStabilizerGate { + gate_type: GateType::T + } + )); + } + + #[test] + fn stabilizer_inline_channel_rejects_non_pauli_channel() { + let mut circuit = TickCircuit::new(); + circuit.tick().pz(&[0]); + circuit + .tick() + .channel(pecos_core::channel::AmplitudeDamping(0.5, 0)); + circuit.tick().mz(&[0]); + + let err = run_inline_pauli_channels_stabilizer(&circuit, 1, 123).unwrap_err(); + + assert!(matches!( + err, + InlineChannelError::NonPauliMixedUnitaryChannel + )); + } +} diff --git a/exp/pecos-neo/src/lib.rs b/exp/pecos-neo/src/lib.rs index 7c309e8d4..e9296bdb9 100644 --- a/exp/pecos-neo/src/lib.rs +++ b/exp/pecos-neo/src/lib.rs @@ -149,6 +149,7 @@ pub mod command; pub mod ecs; pub mod engines; pub mod extensible; +pub mod inline_channel; pub mod noise; pub mod outcome; pub mod program; diff --git a/exp/pecos-neo/src/noise/single_qubit.rs b/exp/pecos-neo/src/noise/single_qubit.rs index 109cb8a58..1cfa6342c 100644 --- a/exp/pecos-neo/src/noise/single_qubit.rs +++ b/exp/pecos-neo/src/noise/single_qubit.rs @@ -217,10 +217,12 @@ impl NoiseChannel for SingleQubitChannel { if self.error_probability <= 0.0 { return false; } - // Respond to BeforeGate for leaked qubit handling and AfterGate for noise + // Respond only to unitary single-qubit gates. + // Preparations (PZ, QAlloc) and measurements (MZ) have their own + // noise channels and should not get gate depolarizing noise. match event { NoiseEvent::BeforeGate { gate_type, .. } | NoiseEvent::AfterGate { gate_type, .. } => { - gate_type.is_single_qubit() + gate_type.is_single_qubit() && gate_type.is_unitary_gate() } _ => false, } @@ -272,7 +274,7 @@ impl NoiseChannel for SingleQubitChannel { NoiseEvent::BeforeGate { gate_type, qubits, .. } => { - if !gate_type.is_single_qubit() { + if !gate_type.is_single_qubit() || !gate_type.is_unitary_gate() { return None; } // Skip noise for noiseless gates (but still check leakage) @@ -284,7 +286,7 @@ impl NoiseChannel for SingleQubitChannel { NoiseEvent::AfterGate { gate_type, qubits, .. } => { - if !gate_type.is_single_qubit() { + if !gate_type.is_single_qubit() || !gate_type.is_unitary_gate() { return None; } // Skip noise for noiseless gates diff --git a/exp/pecos-neo/src/noise/two_qubit.rs b/exp/pecos-neo/src/noise/two_qubit.rs index bbfdeec0e..c5770993c 100644 --- a/exp/pecos-neo/src/noise/two_qubit.rs +++ b/exp/pecos-neo/src/noise/two_qubit.rs @@ -436,9 +436,12 @@ impl NoiseChannel for TwoQubitChannel { if self.error_probability <= 0.0 { return false; } + // Only respond to unitary two-qubit gates. + // Non-unitary two-qubit operations (if any) should not get + // gate depolarizing noise. match event { NoiseEvent::BeforeGate { gate_type, .. } | NoiseEvent::AfterGate { gate_type, .. } => { - gate_type.is_two_qubit() + gate_type.is_two_qubit() && gate_type.is_unitary_gate() } _ => false, } @@ -493,7 +496,7 @@ impl NoiseChannel for TwoQubitChannel { NoiseEvent::BeforeGate { gate_type, qubits, .. } => { - if !gate_type.is_two_qubit() { + if !gate_type.is_two_qubit() || !gate_type.is_unitary_gate() { return None; } if ctx.is_noiseless(*gate_type) { @@ -507,7 +510,7 @@ impl NoiseChannel for TwoQubitChannel { angles, .. } => { - if !gate_type.is_two_qubit() { + if !gate_type.is_two_qubit() || !gate_type.is_unitary_gate() { return None; } if ctx.is_noiseless(*gate_type) { diff --git a/exp/pecos-neo/src/runner.rs b/exp/pecos-neo/src/runner.rs index 25d679e07..08094abfe 100644 --- a/exp/pecos-neo/src/runner.rs +++ b/exp/pecos-neo/src/runner.rs @@ -1443,6 +1443,14 @@ impl CircuitRunner { sim.h(qubits); true } + GateType::F => { + sim.f(qubits); + true + } + GateType::Fdg => { + sim.fdg(qubits); + true + } GateType::SX => { sim.sx(qubits); true @@ -1492,6 +1500,26 @@ impl CircuitRunner { sim.szzdg(&pairs); true } + GateType::SXX => { + let pairs = flat_to_pairs(qubits); + sim.sxx(&pairs); + true + } + GateType::SXXdg => { + let pairs = flat_to_pairs(qubits); + sim.sxxdg(&pairs); + true + } + GateType::SYY => { + let pairs = flat_to_pairs(qubits); + sim.syy(&pairs); + true + } + GateType::SYYdg => { + let pairs = flat_to_pairs(qubits); + sim.syydg(&pairs); + true + } GateType::SWAP => { let pairs = flat_to_pairs(qubits); sim.swap(&pairs); @@ -1566,9 +1594,9 @@ impl CircuitRunner { // Perform measurement let results = sim.mz(&[qubit]); - let meas_result = results.first(); - let outcome = meas_result.is_some_and(|r| r.outcome); - let is_deterministic = meas_result.is_none_or(|r| r.is_deterministic); + let meas_id = results.first(); + let outcome = meas_id.is_some_and(|r| r.outcome); + let is_deterministic = meas_id.is_none_or(|r| r.is_deterministic); // Store result for conditionals if let Some(slot) = self.results.get_mut(result_id.0 as usize) { @@ -2062,7 +2090,7 @@ impl CircuitRunner { NoiseResponse::InjectGates(gates) => { for gate in gates.iter() { - Self::execute_noise_gate(sim, gate); + self.execute_noise_gate(sim, gate); } } @@ -2092,8 +2120,11 @@ impl CircuitRunner { } } - /// Execute a noise gate (injected Pauli error). - fn execute_noise_gate(sim: &mut S, gate: &GateCommand) { + /// Execute a noise gate (injected error). + /// + /// Handles Pauli gates directly. For non-Pauli gates (rotations, Cliffords), + /// delegates to the rotation executor if available, otherwise skips. + fn execute_noise_gate(&self, sim: &mut S, gate: &GateCommand) { let qubits = gate.qubits.as_slice(); match gate.gate_type { GateType::X => { @@ -2105,7 +2136,81 @@ impl CircuitRunner { GateType::Z => { sim.z(qubits); } - _ => {} + GateType::H => { + sim.h(qubits); + } + GateType::F => { + sim.f(qubits); + } + GateType::Fdg => { + sim.fdg(qubits); + } + GateType::SX => { + sim.sx(qubits); + } + GateType::SXdg => { + sim.sxdg(qubits); + } + GateType::SY => { + sim.sy(qubits); + } + GateType::SYdg => { + sim.sydg(qubits); + } + GateType::SZ => { + sim.sz(qubits); + } + GateType::SZdg => { + sim.szdg(qubits); + } + GateType::CX => { + let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + sim.cx(&pairs); + } + GateType::CY => { + let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + sim.cy(&pairs); + } + GateType::CZ => { + let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + sim.cz(&pairs); + } + GateType::SXX => { + let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + sim.sxx(&pairs); + } + GateType::SXXdg => { + let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + sim.sxxdg(&pairs); + } + GateType::SYY => { + let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + sim.syy(&pairs); + } + GateType::SYYdg => { + let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + sim.syydg(&pairs); + } + GateType::SZZ => { + let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + sim.szz(&pairs); + } + GateType::SZZdg => { + let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + sim.szzdg(&pairs); + } + GateType::SWAP => { + let pairs: Vec<_> = qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + sim.swap(&pairs); + } + // Non-Clifford gates: delegate to rotation executor + other => { + if let Some(executor) = self.rotation_executor { + executor(sim, GateId::from(other), gate.angles.as_slice(), qubits); + } + // If no rotation executor, silently skip (noise channel injected + // a gate the simulator can't handle). + } } } } @@ -2292,6 +2397,9 @@ mod tests { use crate::command::CommandBuilder; use crate::extensible::{GateCategory, GateSpec, OpBuilder, gates}; use crate::noise::single_qubit::SingleQubitChannel; + use num_complex::Complex64; + use pecos_core::clifford::Clifford; + use pecos_quantum::unitary_matrix::{ToMatrix, UnitaryMatrix}; use pecos_simulators::{SparseStab, StateVec}; // --- Basic execution tests --- @@ -2934,4 +3042,223 @@ mod tests { let outcomes = runner.take_outcomes(); assert_eq!(outcomes.len(), 1); } + + #[test] + fn test_apply_gate_standard_clifford_inverse_sequences() { + let mut state = SparseStab::new(2); + let mut runner = CircuitRunner::::new().with_seed(42); + + runner + .apply_gate(&mut state, GateType::PZ, &[QubitId(0), QubitId(1)], &[]) + .unwrap(); + runner + .apply_gate(&mut state, GateType::F, &[QubitId(0)], &[]) + .unwrap(); + runner + .apply_gate(&mut state, GateType::Fdg, &[QubitId(0)], &[]) + .unwrap(); + runner + .apply_gate(&mut state, GateType::SXX, &[QubitId(0), QubitId(1)], &[]) + .unwrap(); + runner + .apply_gate(&mut state, GateType::SXXdg, &[QubitId(0), QubitId(1)], &[]) + .unwrap(); + runner + .apply_gate(&mut state, GateType::MZ, &[QubitId(0), QubitId(1)], &[]) + .unwrap(); + + let outcomes = runner.take_outcomes(); + assert_eq!(outcomes.len(), 2); + for outcome in outcomes.iter() { + assert!( + !outcome.outcome, + "inverse Clifford sequence should preserve |0>" + ); + assert!(outcome.is_deterministic); + } + } + + #[test] + fn test_apply_gate_standard_clifford_inverse_table() { + let cases = [ + (GateType::I, GateType::I, vec![QubitId(0)]), + (GateType::X, GateType::X, vec![QubitId(0)]), + (GateType::Y, GateType::Y, vec![QubitId(0)]), + (GateType::Z, GateType::Z, vec![QubitId(0)]), + (GateType::H, GateType::H, vec![QubitId(0)]), + (GateType::F, GateType::Fdg, vec![QubitId(0)]), + (GateType::Fdg, GateType::F, vec![QubitId(0)]), + (GateType::SX, GateType::SXdg, vec![QubitId(0)]), + (GateType::SXdg, GateType::SX, vec![QubitId(0)]), + (GateType::SY, GateType::SYdg, vec![QubitId(0)]), + (GateType::SYdg, GateType::SY, vec![QubitId(0)]), + (GateType::SZ, GateType::SZdg, vec![QubitId(0)]), + (GateType::SZdg, GateType::SZ, vec![QubitId(0)]), + (GateType::CX, GateType::CX, vec![QubitId(0), QubitId(1)]), + (GateType::CY, GateType::CY, vec![QubitId(0), QubitId(1)]), + (GateType::CZ, GateType::CZ, vec![QubitId(0), QubitId(1)]), + (GateType::SXX, GateType::SXXdg, vec![QubitId(0), QubitId(1)]), + (GateType::SXXdg, GateType::SXX, vec![QubitId(0), QubitId(1)]), + (GateType::SYY, GateType::SYYdg, vec![QubitId(0), QubitId(1)]), + (GateType::SYYdg, GateType::SYY, vec![QubitId(0), QubitId(1)]), + (GateType::SZZ, GateType::SZZdg, vec![QubitId(0), QubitId(1)]), + (GateType::SZZdg, GateType::SZZ, vec![QubitId(0), QubitId(1)]), + (GateType::SWAP, GateType::SWAP, vec![QubitId(0), QubitId(1)]), + ]; + + for (gate, inverse, qubits) in cases { + let mut state = SparseStab::new(2); + let mut runner = CircuitRunner::::new().with_seed(42); + + runner + .apply_gate(&mut state, GateType::PZ, &[QubitId(0), QubitId(1)], &[]) + .unwrap(); + runner.apply_gate(&mut state, gate, &qubits, &[]).unwrap(); + runner + .apply_gate(&mut state, inverse, &qubits, &[]) + .unwrap(); + runner + .apply_gate(&mut state, GateType::MZ, &[QubitId(0), QubitId(1)], &[]) + .unwrap(); + + let outcomes = runner.take_outcomes(); + assert_eq!(outcomes.len(), 2, "{gate:?} then {inverse:?}"); + for outcome in outcomes.iter() { + assert!( + !outcome.outcome, + "{gate:?} then {inverse:?} should preserve |00>" + ); + assert!(outcome.is_deterministic); + } + } + } + + fn matrix_times_state(matrix: &UnitaryMatrix, state: &[Complex64]) -> Vec { + (0..matrix.nrows()) + .map(|row| { + let mut value = Complex64::new(0.0, 0.0); + for col in 0..matrix.ncols() { + value += matrix[(row, col)] * state[col]; + } + value + }) + .collect() + } + + fn assert_states_close(actual: &[Complex64], expected: &[Complex64], label: &str) { + const TOLERANCE: f64 = 1e-10; + assert_eq!(actual.len(), expected.len(), "{label}"); + + let phase = actual + .iter() + .zip(expected.iter()) + .find(|(a, e)| a.norm() > TOLERANCE || e.norm() > TOLERANCE) + .map_or(Complex64::new(1.0, 0.0), |(a, e)| { + assert!( + a.norm() > TOLERANCE && e.norm() > TOLERANCE, + "{label}: support differs, actual={a:?}, expected={e:?}" + ); + *a / *e + }); + + for (idx, (a, e)) in actual.iter().zip(expected.iter()).enumerate() { + let diff = (*a - phase * *e).norm(); + assert!( + diff < TOLERANCE, + "{label}: amplitude {idx} differs up to global phase {phase:?}, actual={a:?}, expected={e:?}, diff={diff}" + ); + } + } + + fn prepare_matrix_test_state(prep_index: usize) -> StateVec { + let mut state = StateVec::new(2); + match prep_index { + 0 => {} + 1 => { + state.x(&[QubitId(0)]); + } + 2 => { + state.x(&[QubitId(1)]); + } + 3 => { + state.h(&[QubitId(0)]); + state.cx(&[(QubitId(0), QubitId(1))]); + } + 4 => { + state.sx(&[QubitId(0)]); + state.h(&[QubitId(1)]); + } + _ => unreachable!(), + } + state + } + + #[test] + fn test_apply_gate_standard_cliffords_match_unitary_matrices() { + let one_qubit_cases = [ + (GateType::I, Clifford::I), + (GateType::X, Clifford::X), + (GateType::Y, Clifford::Y), + (GateType::Z, Clifford::Z), + (GateType::H, Clifford::H), + (GateType::F, Clifford::F), + (GateType::Fdg, Clifford::Fdg), + (GateType::SX, Clifford::SX), + (GateType::SXdg, Clifford::SXdg), + (GateType::SY, Clifford::SY), + (GateType::SYdg, Clifford::SYdg), + (GateType::SZ, Clifford::SZ), + (GateType::SZdg, Clifford::SZdg), + ]; + let two_qubit_cases = [ + (GateType::CX, Clifford::CX), + (GateType::CY, Clifford::CY), + (GateType::CZ, Clifford::CZ), + (GateType::SXX, Clifford::SXX), + (GateType::SXXdg, Clifford::SXXdg), + (GateType::SYY, Clifford::SYY), + (GateType::SYYdg, Clifford::SYYdg), + (GateType::SZZ, Clifford::SZZ), + (GateType::SZZdg, Clifford::SZZdg), + (GateType::SWAP, Clifford::SWAP), + ]; + + for prep_index in 0..5 { + for (gate_type, clifford) in one_qubit_cases { + let mut state = prepare_matrix_test_state(prep_index); + let input = state.state(); + let expected = matrix_times_state(&clifford.on_qubit(1).to_matrix(), &input); + + let mut runner = CircuitRunner::::new().with_seed(42); + runner + .apply_gate(&mut state, gate_type, &[QubitId(1)], &[]) + .unwrap(); + let actual = state.state(); + + assert_states_close( + &actual, + &expected, + &format!("{gate_type:?} on prep {prep_index}"), + ); + } + + for (gate_type, clifford) in two_qubit_cases { + let mut state = prepare_matrix_test_state(prep_index); + let input = state.state(); + let expected = matrix_times_state(&clifford.on_qubits(0, 1).to_matrix(), &input); + + let mut runner = CircuitRunner::::new().with_seed(42); + runner + .apply_gate(&mut state, gate_type, &[QubitId(0), QubitId(1)], &[]) + .unwrap(); + let actual = state.state(); + + assert_states_close( + &actual, + &expected, + &format!("{gate_type:?} on prep {prep_index}"), + ); + } + } + } } diff --git a/exp/pecos-neo/src/sampling/importance_runner.rs b/exp/pecos-neo/src/sampling/importance_runner.rs index 22f189e6d..bcf1bf036 100644 --- a/exp/pecos-neo/src/sampling/importance_runner.rs +++ b/exp/pecos-neo/src/sampling/importance_runner.rs @@ -630,6 +630,12 @@ impl ImportanceSamplingRunner { GateType::H => { self.simulator.h(&qubits); } + GateType::F => { + self.simulator.f(&qubits); + } + GateType::Fdg => { + self.simulator.fdg(&qubits); + } GateType::SX => { self.simulator.sx(&qubits); } @@ -675,6 +681,26 @@ impl ImportanceSamplingRunner { qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); self.simulator.szzdg(&pairs); } + GateType::SXX => { + let pairs: Vec<(QubitId, QubitId)> = + qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); + self.simulator.sxx(&pairs); + } + GateType::SXXdg => { + let pairs: Vec<(QubitId, QubitId)> = + qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); + self.simulator.sxxdg(&pairs); + } + GateType::SYY => { + let pairs: Vec<(QubitId, QubitId)> = + qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); + self.simulator.syy(&pairs); + } + GateType::SYYdg => { + let pairs: Vec<(QubitId, QubitId)> = + qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); + self.simulator.syydg(&pairs); + } GateType::SWAP => { let pairs: Vec<(QubitId, QubitId)> = qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); @@ -892,6 +918,25 @@ mod tests { // (unless by chance the proposal probability matched the decision) } + #[test] + fn test_importance_runner_handles_face_inverse_deterministically() { + let commands = CommandBuilder::new() + .pz(&[0]) + .f(&[0]) + .fdg(&[0]) + .mz(&[0]) + .build(); + + let mut runner = ImportanceSamplingRunner::new(SparseStab::new(1)).with_seed(42); + let result = runner.run_shot(&commands); + + assert_eq!(result.outcomes.len(), 1); + let outcome = result.outcomes.get(QubitId(0)).unwrap(); + assert!(!outcome.outcome); + assert!(outcome.is_deterministic); + assert!((result.weight.weight() - 1.0).abs() < 1e-10); + } + #[test] fn test_importance_sampling_estimates_correct_rate() { // This test verifies that importance sampling produces diff --git a/exp/pecos-neo/src/sampling/path.rs b/exp/pecos-neo/src/sampling/path.rs index 2fe42db37..d6f3cc705 100644 --- a/exp/pecos-neo/src/sampling/path.rs +++ b/exp/pecos-neo/src/sampling/path.rs @@ -535,6 +535,12 @@ impl PathExplorer { GateType::H => { self.simulator.h(&qubits); } + GateType::F => { + self.simulator.f(&qubits); + } + GateType::Fdg => { + self.simulator.fdg(&qubits); + } GateType::SX => { self.simulator.sx(&qubits); } @@ -578,6 +584,26 @@ impl PathExplorer { qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); self.simulator.szzdg(&pairs); } + GateType::SXX => { + let pairs: Vec<(QubitId, QubitId)> = + qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); + self.simulator.sxx(&pairs); + } + GateType::SXXdg => { + let pairs: Vec<(QubitId, QubitId)> = + qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); + self.simulator.sxxdg(&pairs); + } + GateType::SYY => { + let pairs: Vec<(QubitId, QubitId)> = + qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); + self.simulator.syy(&pairs); + } + GateType::SYYdg => { + let pairs: Vec<(QubitId, QubitId)> = + qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); + self.simulator.syydg(&pairs); + } GateType::SWAP => { let pairs: Vec<(QubitId, QubitId)> = qubits.chunks_exact(2).map(|c| (c[0], c[1])).collect(); @@ -759,6 +785,42 @@ mod tests { assert!(result1.outcomes.get_bit(QubitId(0)).unwrap()); } + #[test] + fn test_path_explorer_records_face_inverse_as_deterministic() { + let commands = CommandBuilder::new() + .pz(&[0]) + .f(&[0]) + .fdg(&[0]) + .mz(&[0]) + .build(); + + let mut explorer = PathExplorer::new(SparseStab::new(1)).with_seed(42); + let result = explorer.run_and_record(&commands); + + assert!(!result.outcomes.get_bit(QubitId(0)).unwrap()); + assert_eq!(result.path.len(), 1); + let path_outcome = result.path.get(0).unwrap(); + assert!(!path_outcome.outcome); + assert!(path_outcome.is_deterministic); + } + + #[test] + fn test_path_explorer_replays_face_inverse_as_deterministic() { + let commands = CommandBuilder::new() + .pz(&[0]) + .f(&[0]) + .fdg(&[0]) + .mz(&[0]) + .build(); + let mut explorer = PathExplorer::new(SparseStab::new(1)); + let forced_one = EnumeratedPath { bits: 1, len: 1 }; + + let result = explorer.run_with_path(&commands, &forced_one); + + assert!(!result.outcomes.get_bit(QubitId(0)).unwrap()); + assert!(result.path.get(0).unwrap().is_deterministic); + } + #[test] fn test_path_enumeration_statistics() { // Simple circuit: H then measure diff --git a/exp/pecos-neo/src/tool.rs b/exp/pecos-neo/src/tool.rs index 478c23224..6803f8937 100644 --- a/exp/pecos-neo/src/tool.rs +++ b/exp/pecos-neo/src/tool.rs @@ -96,11 +96,11 @@ pub use importance::{ pub use plugin::{Plugin, PluginGroup}; pub use resource::{Resource, Resources}; pub use simulation::{ - Circuit, CustomBackendBuilder, ImportanceSamplingBuilder, NoiseResource, Orchestrator, - QuantumBackend, SimConfig, SimNeoBuilder, SimNeoInput, Simulation, SimulationResults, + Circuit, CustomBackendBuilder, ImportanceSamplingBuilder, NoiseResource, QuantumBackend, + Sampling, SimConfig, SimNeoBuilder, SimNeoInput, Simulation, SimulationResults, SimulatorFactory, SparseStabBuilder, StateVecBuilder, StoredOverrides, custom_backend, - custom_backend_with_rotations, importance_sampling, sim_neo, sim_neo_builder, sparse_stab, - state_vector, + custom_backend_from_factory, custom_backend_with_rotations, importance_sampling, sim_neo, + sim_neo_builder, sparse_stab, state_vector, }; #[cfg(feature = "engines-adapter")] pub use simulation::{PendingEngineBuilder, TypedProgram}; diff --git a/exp/pecos-neo/src/tool/simulation.rs b/exp/pecos-neo/src/tool/simulation.rs index 5a4ca5736..d218dc1cd 100644 --- a/exp/pecos-neo/src/tool/simulation.rs +++ b/exp/pecos-neo/src/tool/simulation.rs @@ -363,6 +363,20 @@ where } } +/// Create a custom backend from a `SimulatorFactory` implementation. +/// +/// Unlike [`custom_backend()`] which takes a closure, this accepts any type +/// implementing `SimulatorFactory` directly. Use this when the factory needs +/// configuration state (e.g., `StabMpsBackend` with bond dimension settings). +#[must_use] +pub fn custom_backend_from_factory( + factory: impl SimulatorFactory + 'static, +) -> CustomBackendBuilder { + CustomBackendBuilder { + factory: Box::new(factory), + } +} + /// Create a custom backend with rotation support from a factory closure. /// /// Like [`custom_backend()`], but enables rotation gates (T, RZ, etc.) for @@ -646,7 +660,7 @@ impl Default for SimConfig { /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// let results = sim_neo(circuit) -/// .orchestrator(importance_sampling() +/// .sampling(importance_sampling() /// .with_p1(0.001) /// .with_p2(0.01) /// .with_p_meas(0.001) @@ -721,10 +735,10 @@ impl ImportanceSamplingBuilder { self } - /// Build the orchestrator. + /// Build the sampling. #[must_use] - pub fn build(self) -> Orchestrator { - Orchestrator::ImportanceSampling { config: self } + pub fn build(self) -> Sampling { + Sampling::ImportanceSampling { config: self } } /// Get the single-qubit error rate. @@ -758,13 +772,13 @@ impl Default for ImportanceSamplingBuilder { } } -impl From for Orchestrator { +impl From for Sampling { fn from(builder: ImportanceSamplingBuilder) -> Self { builder.build() } } -/// Create an importance sampling orchestrator builder. +/// Create an importance sampling sampling builder. /// /// Importance sampling biases noise toward higher error rates to observe /// rare events more frequently, then reweights results for unbiased estimates. @@ -777,7 +791,7 @@ impl From for Orchestrator { /// /// let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); /// let results = sim_neo(circuit) -/// .orchestrator(importance_sampling() +/// .sampling(importance_sampling() /// .with_p1(0.001) /// .with_p2(0.01) /// .with_boost(10.0)) @@ -809,7 +823,7 @@ pub fn importance_sampling() -> ImportanceSamplingBuilder { /// using the Tool/Schedule/Plugin system. Use `.workers(n)` or `.auto_workers()` /// for parallel execution. #[derive(Debug, Clone)] -pub enum Orchestrator { +pub enum Sampling { /// Monte Carlo execution (sequential with 1 worker, parallel with >1). /// /// Each worker runs a batch of shots independently with deterministic seeding. @@ -832,20 +846,20 @@ pub enum Orchestrator { }, } -impl Default for Orchestrator { +impl Default for Sampling { fn default() -> Self { Self::MonteCarlo { workers: 1 } } } -impl Orchestrator { - /// Create a Monte Carlo orchestrator with specified workers. +impl Sampling { + /// Create a Monte Carlo sampling with specified workers. #[must_use] pub fn monte_carlo(workers: usize) -> Self { Self::MonteCarlo { workers } } - /// Create a Monte Carlo orchestrator with auto-detected worker count. + /// Create a Monte Carlo sampling with auto-detected worker count. #[must_use] pub fn monte_carlo_auto() -> Self { let workers = std::thread::available_parallelism().map_or(1, std::num::NonZero::get); @@ -1285,7 +1299,7 @@ pub struct SimNeoBuilder { /// Simulation configuration (data). config: SimConfig, /// Orchestration strategy (data). - orchestrator: Orchestrator, + sampling: Sampling, /// Quantum backend configuration (data). quantum_backend: QuantumBackend, /// Explicit qubit count override (data). @@ -1309,7 +1323,7 @@ impl SimNeoBuilder { noise: None, definitions: None, config: SimConfig::default(), - orchestrator: Orchestrator::default(), + sampling: Sampling::default(), quantum_backend: QuantumBackend::default(), explicit_num_qubits: None, max_decomp_depth: None, @@ -1330,7 +1344,7 @@ impl SimNeoBuilder { noise: None, definitions: None, config: SimConfig::default(), - orchestrator: Orchestrator::default(), + sampling: Sampling::default(), quantum_backend: QuantumBackend::default(), explicit_num_qubits: None, max_decomp_depth: None, @@ -1352,7 +1366,7 @@ impl SimNeoBuilder { noise: None, definitions: None, config: SimConfig::default(), - orchestrator: Orchestrator::default(), + sampling: Sampling::default(), quantum_backend: QuantumBackend::default(), explicit_num_qubits: None, max_decomp_depth: None, @@ -1379,7 +1393,7 @@ impl SimNeoBuilder { noise: None, definitions: None, config: SimConfig::default(), - orchestrator: Orchestrator::default(), + sampling: Sampling::default(), quantum_backend: QuantumBackend::default(), explicit_num_qubits: None, max_decomp_depth: None, @@ -1616,9 +1630,9 @@ impl SimNeoBuilder { // Classical engines are stateful and cannot be parallelized across workers. // Static circuits can run in parallel since each worker gets its own simulator. if is_classical { - self.orchestrator = Orchestrator::MonteCarlo { workers: 1 }; + self.sampling = Sampling::MonteCarlo { workers: 1 }; } else { - self.orchestrator = Orchestrator::monte_carlo_auto(); + self.sampling = Sampling::monte_carlo_auto(); } self } @@ -1652,28 +1666,28 @@ impl SimNeoBuilder { /// # Example /// /// ```no_run - /// use pecos_neo::tool::{sim_neo, Orchestrator}; + /// use pecos_neo::tool::{sim_neo, Sampling}; /// 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()) - /// .orchestrator(Orchestrator::monte_carlo(4)) + /// .sampling(Sampling::monte_carlo(4)) /// .shots(1000) /// .build() /// .run(); /// /// // Auto-detect worker count /// let results = sim_neo(circuit) - /// .orchestrator(Orchestrator::monte_carlo_auto()) + /// .sampling(Sampling::monte_carlo_auto()) /// .shots(1000) /// .build() /// .run(); /// ``` #[must_use] - pub fn orchestrator(mut self, orchestrator: impl Into) -> Self { - self.orchestrator = orchestrator.into(); + pub fn sampling(mut self, sampling: impl Into) -> Self { + self.sampling = sampling.into(); self } @@ -1691,7 +1705,7 @@ impl SimNeoBuilder { /// backends are not supported. #[must_use] pub fn workers(mut self, workers: usize) -> Self { - self.orchestrator = Orchestrator::monte_carlo(workers); + self.sampling = Sampling::monte_carlo(workers); self } @@ -1700,7 +1714,7 @@ impl SimNeoBuilder { /// See [`workers()`](Self::workers) for requirements and panics. #[must_use] pub fn auto_workers(mut self) -> Self { - self.orchestrator = Orchestrator::monte_carlo_auto(); + self.sampling = Sampling::monte_carlo_auto(); self } @@ -2035,14 +2049,14 @@ impl SimNeoBuilder { .insert_resource(self.config) .insert_resource(QuantumBackendResource(self.quantum_backend)); - match &self.orchestrator { - Orchestrator::ImportanceSampling { config: is_config } => { + match &self.sampling { + Sampling::ImportanceSampling { config: is_config } => { tool = tool.add_plugin(&ImportanceSamplingSimPlugin { is_config: is_config.clone(), explicit_num_qubits: self.explicit_num_qubits, }); } - Orchestrator::MonteCarlo { .. } => { + Sampling::MonteCarlo { .. } => { tool = tool.add_plugin(&UnifiedSimulationPlugin { explicit_num_qubits: self.explicit_num_qubits, }); @@ -2076,7 +2090,7 @@ impl SimNeoBuilder { Simulation { tool, - orchestrator: self.orchestrator, + sampling: self.sampling, parallel_data, } } @@ -2385,7 +2399,7 @@ fn unified_simulation_post_shot(resources: &mut Resources) { /// Plugin for importance-sampling simulation. /// -/// Replaces [`UnifiedSimulationPlugin`] when the IS orchestrator is selected. +/// Replaces [`UnifiedSimulationPlugin`] when the IS sampling is selected. /// Uses [`ImportanceSamplingRunner`] for biased noise with weight tracking. struct ImportanceSamplingSimPlugin { is_config: ImportanceSamplingBuilder, @@ -2556,7 +2570,7 @@ fn is_sim_post_shot(resources: &mut Resources) { pub struct Simulation { tool: Tool, /// Orchestration strategy (stored as data). - orchestrator: Orchestrator, + sampling: Sampling, /// Data for parallel execution (if applicable). /// Stored separately from Tool to allow cloning for workers. parallel_data: Option, @@ -2610,7 +2624,7 @@ impl Simulation { /// Returns the simulation results. The simulation can be run again /// after reconfiguring with [`shots()`](Self::shots) or [`seed()`](Self::seed). /// - /// Execution strategy depends on the orchestrator: + /// Execution strategy depends on the sampling: /// - `MonteCarlo { workers: 1 }`: Runs shots via the Tool (default) /// - `MonteCarlo { workers: n }`: Parallelizes shots across n workers /// - `ImportanceSampling`: Runs via the Tool with `ImportanceSamplingSimPlugin` @@ -2621,8 +2635,8 @@ impl Simulation { let config = self.tool.resource::().clone(); // Dispatch based on orchestration strategy - match &self.orchestrator { - Orchestrator::MonteCarlo { workers } if *workers > 1 => { + match &self.sampling { + Sampling::MonteCarlo { workers } if *workers > 1 => { let data = self.parallel_data.as_ref().unwrap_or_else(|| { panic!( "Parallel Monte Carlo requires a static circuit \ @@ -3316,14 +3330,14 @@ mod tests { #[test] fn test_sim_neo_orchestrator_explicit() { - // Test explicit orchestrator configuration - use super::Orchestrator; + // Test explicit sampling configuration + use super::Sampling; let circuit = CommandBuilder::new().pz(&[0]).x(&[0]).mz(&[0]).build(); - // Use explicit Orchestrator enum + // Use explicit Sampling enum let results = sim_neo(circuit) - .orchestrator(Orchestrator::monte_carlo(2)) + .sampling(Sampling::monte_carlo(2)) .shots(20) .seed(42) .run(); @@ -3351,7 +3365,7 @@ mod tests { // Run with default (1 worker) let single_results = sim_neo(circuit.clone()).shots(50).seed(42).run(); - // Run with parallel Monte Carlo orchestrator (4 workers) + // Run with parallel Monte Carlo sampling (4 workers) let parallel_results = sim_neo(circuit).workers(4).shots(50).seed(42).run(); // Results should be identical @@ -3396,7 +3410,7 @@ mod tests { .seed(42) .run(); - // Run with parallel Monte Carlo orchestrator + // Run with parallel Monte Carlo sampling let parallel_results = sim_neo(circuit) .noise(noise_par) .workers(4) @@ -3585,7 +3599,7 @@ mod tests { } } - // --- Importance Sampling Orchestrator Tests --- + // --- Importance Sampling Sampling Tests --- #[test] fn test_sim_neo_importance_sampling_basic() { @@ -3599,7 +3613,7 @@ mod tests { .build(); let results = sim_neo(circuit) - .orchestrator( + .sampling( importance_sampling() .with_p1(0.01) .with_p2(0.02) @@ -3628,7 +3642,7 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) - .orchestrator( + .sampling( importance_sampling() .with_uniform_error(0.01) .with_boost(10.0), @@ -3655,7 +3669,7 @@ mod tests { // Run with importance sampling (boosting noise that doesn't affect this test) let results = sim_neo(circuit) - .orchestrator( + .sampling( importance_sampling() .with_uniform_error(0.001) .with_boost(100.0), @@ -3694,13 +3708,13 @@ mod tests { .with_boost(10.0); let results1 = sim_neo(circuit.clone()) - .orchestrator(is_builder.clone()) + .sampling(is_builder.clone()) .shots(20) .seed(42) .run(); let results2 = sim_neo(circuit) - .orchestrator(is_builder) + .sampling(is_builder) .shots(20) .seed(42) .run(); @@ -3745,7 +3759,7 @@ mod tests { .build(); let results = sim_neo(circuit) - .orchestrator( + .sampling( importance_sampling() .with_p1(0.001) .with_p2(0.01) @@ -3768,7 +3782,7 @@ mod tests { let circuit = CommandBuilder::new().pz(&[0]).h(&[0]).mz(&[0]).build(); let results = sim_neo(circuit) - .orchestrator( + .sampling( importance_sampling() .with_uniform_error(0.01) .with_boost(10.0), diff --git a/exp/pecos-stab-tn/src/stab_mps.rs b/exp/pecos-stab-tn/src/stab_mps.rs index 888c2b6f6..4a115d751 100644 --- a/exp/pecos-stab-tn/src/stab_mps.rs +++ b/exp/pecos-stab-tn/src/stab_mps.rs @@ -338,8 +338,11 @@ impl StabMpsBuilder { /// for adversarial T-heavy subcircuits before truncation hits the cap. /// /// Override any of these with subsequent builder calls: - /// ```ignore - /// StabMps::builder(n).for_qec().max_bond_dim(64).build() + /// ``` + /// use pecos_stab_tn::stab_mps::StabMps; + /// + /// let sim = StabMps::builder(4).for_qec().max_bond_dim(64).build(); + /// assert_eq!(sim.num_qubits(), 4); /// ``` #[must_use] pub fn for_qec(self) -> Self { @@ -1880,6 +1883,22 @@ impl QuantumSimulator for StabMps { } } +impl pecos_random::RngManageable for StabMps { + type Rng = PecosRng; + + fn set_rng(&mut self, rng: Self::Rng) { + self.rng = rng; + } + + fn rng(&self) -> &Self::Rng { + &self.rng + } + + fn rng_mut(&mut self) -> &mut Self::Rng { + &mut self.rng + } +} + impl CliffordGateable for StabMps { fn sz(&mut self, qubits: &[QubitId]) -> &mut Self { // SZ commutes with RZ: skip `flush_pending_rz`. The pending RZ @@ -5880,39 +5899,28 @@ mod tests { } #[test] - fn test_pauli_frame_faster_than_eager_for_many_noise_injects() { - // Timing sanity check: many Pauli injections into frame should - // be far faster than applying each to tableau. - use std::time::Instant; - let n = 32; - let num_injects = 10_000; + fn test_pauli_frame_matches_eager_for_many_noise_injects() { + let n = 6; + let num_injects = 1_000; let mut stn_frame = StabMps::builder(n) .seed(1) .pauli_frame_tracking(true) .build(); - let start = Instant::now(); - for _ in 0..num_injects { - stn_frame.apply_depolarizing(QubitId(0), 1.0); - } - let t_frame = start.elapsed().as_secs_f64(); - let mut stn_eager = StabMps::builder(n).seed(1).build(); - let start = Instant::now(); - for _ in 0..num_injects { - stn_eager.apply_depolarizing(QubitId(0), 1.0); + + for k in 0..num_injects { + let q = QubitId(k % n); + let frame_pauli = stn_frame.apply_depolarizing(q, 1.0); + let eager_pauli = stn_eager.apply_depolarizing(q, 1.0); + assert_eq!(frame_pauli, eager_pauli); } - let t_eager = start.elapsed().as_secs_f64(); - // Frame tracking should be at least 2x faster. - eprintln!( - "Pauli frame: {t_frame:.4}s; eager: {t_eager:.4}s → {:.1}x", - t_eager / t_frame - ); - assert!( - t_frame * 2.0 < t_eager, - "frame tracking should be >2x faster: frame={t_frame:.4}s eager={t_eager:.4}s" - ); + stn_frame.flush_pauli_frame_to_state(); + let frame_sv = stn_frame.state_vector(); + let eager_sv = stn_eager.state_vector(); + + assert_state_vectors_match(&frame_sv, &eager_sv, "frame vs eager Pauli injections"); } #[test] diff --git a/exp/pecos-zx/src/viz/circuit_layout.rs b/exp/pecos-zx/src/viz/circuit_layout.rs index f44100f97..f5334f2d8 100644 --- a/exp/pecos-zx/src/viz/circuit_layout.rs +++ b/exp/pecos-zx/src/viz/circuit_layout.rs @@ -285,7 +285,7 @@ pub fn layout_from_tick_circuit(tc: &TickCircuit) -> CircuitLayout { if tick_idx >= num_steps { break; } - for gate in tick.gates().iter() { + for gate in tick.gate_batches().iter() { let qubit_indices: Vec = gate.qubits.iter().map(|q| q.index()).collect(); // TODO: TickCircuit classical bit/condition support diff --git a/mkdocs.yml b/mkdocs.yml index 21e546ce9..d1f4dca87 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -50,15 +50,24 @@ nav: - Home: README.md - User Guide: - Getting Started: user-guide/getting-started.md + - PECOS Concepts: user-guide/pecos-concepts.md - Command Line Interface: user-guide/cli.md - QASM Simulation: user-guide/qasm-simulation.md - WASM Foreign Objects: user-guide/wasm-foreign-objects.md - HUGR & Guppy Simulation: user-guide/hugr-simulation.md - Simulators: user-guide/simulators.md + - Engine Selection: user-guide/engine-selection.md - Gate Reference: user-guide/gates.md - Gate Naming Conventions: user-guide/gate-naming-conventions.md - Gate Naming (Future): user-guide/gate-naming-speculation.md + - Gate and Angle Types: user-guide/gate-angle-types.md - Noise Model Builders: user-guide/noise-model-builders.md + - Quantum Operator Algebra: user-guide/quantum-operator-algebra.md + - Quantum Information Primitives: user-guide/quantum-info.md + - Stabilizer Codes: user-guide/stabilizer-codes.md + - Pauli Algebra and QEC in Python: user-guide/python-pauli-qec.md + - Fault Tolerance Analysis: user-guide/fault-tolerance.md + - Fault Catalog Tutorial: user-guide/fault-catalog.md - QEC Geometry: user-guide/qec-geometry.md - QEC with Guppy: user-guide/qec-guppy.md - Decoders: user-guide/decoders.md @@ -77,9 +86,14 @@ nav: - Developer Tools CLI: development/dev-tools.md - Documentation Testing: development/doc-testing.md - SLR and QECLib: development/slr-qeclib.md - - Circuit Representations: development/circuit-representations.md + - AST Infrastructure: development/ast-infrastructure.md + - Foreign Language Plugins: development/foreign-plugins.md - Parallel Blocks: development/parallel-blocks-and-optimization.md - - development/QIS_ARCHITECTURE.md +- Experimental: + - experimental/index.md + - Composable Noise (pecos-neo): experimental/composable-noise.md +- Proposals: + - proposals/README.md - Releases: - releases/changelog.md markdown_extensions: @@ -126,4 +140,4 @@ extra: link: https://pypi.org/project/quantum-pecos/ - icon: fontawesome/solid/book link: https://quantum-pecos.readthedocs.io/ -copyright: Copyright © 2018-2025 The PECOS Developers +copyright: Copyright © 2018-2026 The PECOS Developers diff --git a/pyproject.toml b/pyproject.toml index 6228d25b1..3bde0ac88 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -1,12 +1,15 @@ [project] name = "pecos-workspace" -version = "0.8.0.dev5" +version = "0.8.0.dev8" +requires-python = ">=3.10" # Meta-package; runtime deps live in the member packages. Test/example/dev # tooling is declared in [dependency-groups] below. dependencies = [] [project.optional-dependencies] -cuda = ["quantum-pecos[cuda]"] +# Keep this in sync with the cuda dependency group below. The extra supports +# `uv sync --extra cuda`; the group supports `uv run --group cuda ...`. +cuda = ["quantum-pecos[cuda]==0.8.0.dev8"] [tool.uv.workspace] members = [ @@ -35,6 +38,7 @@ dev = [ "pre-commit", # Git hooks "black", # Code formatting "ruff", # Fast Python linting + "tomli>=1.1.0; python_version < '3.11'", # TOML parsing (stdlib in 3.11+) "mkdocs", # Documentation framework "mkdocs-material", # Material theme for MkDocs "mkdocstrings[python]", # Code documentation extraction @@ -52,7 +56,6 @@ test = [ # exact pins so workspace tests run against a reproducible environment "pytest-timeout==2.4.0", "hypothesis==6.152.1", "stim==1.15.0", # Stim-comparison and decomposition-invariant tests - "wasmtime==43.0.0", # WebAssembly runtime exercised by integration tests "matplotlib>=2.2.0", # Surface-patch render tests import matplotlib directly ] examples = [ # extras used by the examples/ tree and notebook walkthroughs @@ -64,15 +67,20 @@ numpy-compat = [ # NumPy/SciPy compatibility tests - verify compatibility with s "scipy>=1.1.0", ] cuda = [ # CUDA Python packages for GPU-accelerated simulations (requires CUDA toolkit) - "quantum-pecos[cuda]", + "quantum-pecos[cuda]==0.8.0.dev8", ] [tool.uv] default-groups = ["dev", "test"] -# Prevent uv from caching pecos-rslib wheels - always use freshly built version -# This fixes stale code issues when uv sync/run reinstalls cached old wheels +# Prevent uv from caching native workspace wheels - always use freshly built versions. +# This fixes stale code issues when uv sync/run reinstalls cached old wheels. # See: https://github.com/astral-sh/uv/issues/11390 -reinstall-package = ["pecos-rslib", "pecos-rslib-cuda", "pecos-rslib-llvm"] +reinstall-package = [ + "pecos-rslib", + "pecos-rslib-cuda", + "pecos-rslib-exp", + "pecos-rslib-llvm", +] [tool.black] line-length = 120 diff --git a/python/pecos-rslib-cuda/pyproject.toml b/python/pecos-rslib-cuda/pyproject.toml index 3e96e850b..7d48eccb5 100644 --- a/python/pecos-rslib-cuda/pyproject.toml +++ b/python/pecos-rslib-cuda/pyproject.toml @@ -43,9 +43,6 @@ test = [ "pytest>=9.0", ] -[tool.uv.sources] -pecos-rslib-cuda = { workspace = true } - [tool.pytest.ini_options] markers = [ "cuda: marks tests that require CUDA and cuQuantum (deselect with '-m \"not cuda\"')", diff --git a/python/pecos-rslib-cuda/src/lib.rs b/python/pecos-rslib-cuda/src/lib.rs index 1f3b61047..e68c01c60 100644 --- a/python/pecos-rslib-cuda/src/lib.rs +++ b/python/pecos-rslib-cuda/src/lib.rs @@ -19,7 +19,9 @@ use pecos_core::{Angle64, QubitId}; use pecos_cuquantum::{ ArbitraryRotationGateable, CliffordGateable, CuDensityMat, CuStabilizer, CuStateVec, - CuTensorNet, QuantumSimulator, is_cuquantum_available as cuquantum_available, + CuTensorNet, QuantumSimulator, is_cudensitymat_usable as cudensitymat_usable, + is_cuquantum_available as cuquantum_available, is_custabilizer_usable as custabilizer_usable, + is_custatevec_usable as custatevec_usable, is_cutensornet_usable as cutensornet_usable, }; use pyo3::exceptions::PyRuntimeError; use pyo3::prelude::*; @@ -32,6 +34,30 @@ fn is_cuquantum_available() -> bool { cuquantum_available() } +/// Check if the cuStateVec backend can create a simulator on this system. +#[pyfunction] +fn is_custatevec_usable() -> bool { + custatevec_usable() +} + +/// Check if the cuStabilizer backend can create a frame simulator on this system. +#[pyfunction] +fn is_custabilizer_usable() -> bool { + custabilizer_usable() +} + +/// Check if the cuTensorNet backend can create a handle on this system. +#[pyfunction] +fn is_cutensornet_usable() -> bool { + cutensornet_usable() +} + +/// Check if the cuDensityMat backend can create a simulator on this system. +#[pyfunction] +fn is_cudensitymat_usable() -> bool { + cudensitymat_usable() +} + /// GPU-accelerated state vector quantum simulator using cuQuantum. /// /// This simulator can handle up to approximately 30 qubits (limited by GPU memory). @@ -851,8 +877,12 @@ impl PyCuDensityMat { fn pecos_rslib_cuda(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { log::debug!("pecos_rslib_cuda module initializing..."); - // Add availability check function + // Add availability check functions m.add_function(wrap_pyfunction!(is_cuquantum_available, m)?)?; + m.add_function(wrap_pyfunction!(is_custatevec_usable, m)?)?; + m.add_function(wrap_pyfunction!(is_custabilizer_usable, m)?)?; + m.add_function(wrap_pyfunction!(is_cutensornet_usable, m)?)?; + m.add_function(wrap_pyfunction!(is_cudensitymat_usable, m)?)?; // Add simulator classes m.add_class::()?; diff --git a/python/pecos-rslib-exp/Cargo.toml b/python/pecos-rslib-exp/Cargo.toml index 8180f9829..82b8162cd 100644 --- a/python/pecos-rslib-exp/Cargo.toml +++ b/python/pecos-rslib-exp/Cargo.toml @@ -18,10 +18,19 @@ doctest = false [dependencies] pecos-core.workspace = true +pecos-eeg.workspace = true +pecos-neo.workspace = true +pecos-qec.workspace = true +pecos-quantum.workspace = true +pecos-random.workspace = true pecos-simulators.workspace = true -pecos-stab-tn = { path = "../../exp/pecos-stab-tn" } +serde = { workspace = true, features = ["derive"] } +serde_json.workspace = true +rayon.workspace = true +pecos-stab-tn.workspace = true pyo3 = { workspace = true, features = ["extension-module", "abi3-py310", "generate-import-lib", "num-complex"] } num-complex.workspace = true +smallvec.workspace = true [lints] workspace = true diff --git a/python/pecos-rslib-exp/src/coherent_idle_channel.rs b/python/pecos-rslib-exp/src/coherent_idle_channel.rs new file mode 100644 index 000000000..5b384666f --- /dev/null +++ b/python/pecos-rslib-exp/src/coherent_idle_channel.rs @@ -0,0 +1,75 @@ +// 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. + +//! Coherent idle noise channel: RZ(angle) on both qubits after each CX gate. + +use pecos_core::Angle64; +use pecos_neo::command::{GateCommand, GateType}; +use pecos_neo::noise::{NoiseChannel, NoiseContext, NoiseEvent, NoiseResponse}; +use pecos_random::PecosRng; +use smallvec::SmallVec; + +/// Applies coherent RZ rotation after each two-qubit gate on both qubits. +/// +/// Models uncompensated phase accumulation during idle time between gates. +/// The rotation angle represents the coherent Z-phase acquired per gate +/// application. +#[derive(Clone)] +pub struct CoherentIdleChannel { + angle: Angle64, +} + +impl CoherentIdleChannel { + /// Create a coherent idle channel with the given RZ angle (radians). + pub fn new(angle_radians: f64) -> Self { + Self { + angle: Angle64::from_radians(angle_radians), + } + } +} + +impl NoiseChannel for CoherentIdleChannel { + fn responds_to(&self, event: &NoiseEvent<'_>) -> bool { + matches!( + event, + NoiseEvent::AfterGate { + gate_type: GateType::CX | GateType::CZ | GateType::CY, + .. + } + ) + } + + fn apply( + &self, + event: &NoiseEvent<'_>, + _ctx: &mut NoiseContext, + _rng: &mut PecosRng, + ) -> NoiseResponse { + if let NoiseEvent::AfterGate { qubits, .. } = event { + let mut gates = SmallVec::new(); + for &q in *qubits { + gates.push(GateCommand::rz(q, self.angle)); + } + NoiseResponse::inject_gates(gates) + } else { + NoiseResponse::None + } + } + + fn name(&self) -> &'static str { + "CoherentIdleRZ" + } + + fn clone_box(&self) -> Box { + Box::new(self.clone()) + } +} diff --git a/python/pecos-rslib-exp/src/eeg_bindings.rs b/python/pecos-rslib-exp/src/eeg_bindings.rs new file mode 100644 index 000000000..c47445fc5 --- /dev/null +++ b/python/pecos-rslib-exp/src/eeg_bindings.rs @@ -0,0 +1,1412 @@ +// Copyright 2026 The PECOS Developers +// +// Licensed under the Apache License, Version 2.0 + +//! Python bindings for EEG DEM builder. + +use pecos_core::pauli::pauli_bitmask::BitmaskStorage; +use pecos_core::{Angle64, Gate, GateAngles, GateMeasIds, GateParams, QubitId}; +use pecos_eeg::Bm; +use pecos_eeg::circuit::{self, NoiseModel}; +use pecos_eeg::correlation_table::CorrelationTableInput; +use pecos_eeg::dem_mapping::{DemEntry, Detector, Observable}; +use pecos_eeg::noise_characterization::NoiseCharacterizationInput; +use pyo3::prelude::*; +use rayon::iter::{IntoParallelRefIterator, ParallelIterator}; +use std::collections::BTreeMap; + +type PyDemEvent = (f64, Vec, Vec); +type PyEegEventDiagnostic = (Vec, usize, usize, Vec, f64); +type MeasurementRecordDefinition = (usize, Vec, Vec); + +/// Build a DEM using forward EEG analysis (perturbative, fast). +/// +/// Returns (raw_dem, decomposed_dem) where the decomposed version uses +/// X/Z Pauli-aware decomposition for MWPM decoders. +/// +/// Fast (milliseconds) but approximate (~50% error for coherent noise). +/// For exact probabilities, use `coherent_dem_decomposed`. +/// +/// h_formula: "taylor" (default), "sin_squared", "exact_commuting", or "exact_subset" +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0, h_formula="taylor", bch_order=1))] +pub fn perturbative_dem( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + h_formula: &str, + bch_order: u32, +) -> PyResult<(String, String)> { + let (raw, decomposable) = run_eeg_decomposable( + tick_circuit, + idle_rz, + p1, + p2, + p_meas, + p_prep, + h_formula, + bch_order, + )?; + Ok(( + pecos_eeg::dem_mapping::format_dem(&raw), + pecos_eeg::dem_mapping::format_dem_decomposed(&decomposable), + )) +} + +/// Build perturbative DEM and return structured events: list of (prob, [det_ids], [obs_ids]). +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0, h_formula="taylor", bch_order=1))] +pub fn perturbative_dem_events( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + h_formula: &str, + bch_order: u32, +) -> PyResult> { + let entries = run_eeg( + tick_circuit, + idle_rz, + p1, + p2, + p_meas, + p_prep, + h_formula, + bch_order, + )?; + Ok(entries + .into_iter() + .map(|e| { + ( + e.probability, + e.event.detectors.to_vec(), + e.event.observables.to_vec(), + ) + }) + .collect()) +} + +/// Return (num_h_generators, num_s_generators, num_detectors, numobservables). +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0))] +pub fn eeg_summary( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, +) -> PyResult<(usize, usize, usize, usize)> { + let noise = NoiseModel { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(tick_circuit)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let result = circuit::analyze_expanded(&expanded.gates, &noise); + let (detectors, observables) = extract_detectors_expanded(tick_circuit, &expanded)?; + let h = result + .generators + .iter() + .filter(|g| g.eeg_type == pecos_eeg::eeg::EegType::H) + .count(); + let s = result + .generators + .iter() + .filter(|g| g.eeg_type == pecos_eeg::eeg::EegType::S) + .count(); + Ok((h, s, detectors.len(), observables.len())) +} + +/// Diagnostic: for each DEM event, return generator details. +/// +/// Returns list of (det_ids, num_labels, num_same_label_groups, rates_by_label, max_combined_rate) +/// This helps understand why perturbative formulas are inaccurate. +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0))] +pub fn eeg_event_diagnostics( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, +) -> PyResult> { + let noise = NoiseModel { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(tick_circuit)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let result = circuit::analyze_expanded(&expanded.gates, &noise); + let (detectors, _observables) = extract_detectors_expanded(tick_circuit, &expanded)?; + + // Group H generators by DEM event, tracking labels + let mut h_events: BTreeMap, BTreeMap> = BTreeMap::new(); + + for g in &result.generators { + if g.eeg_type != pecos_eeg::eeg::EegType::H { + continue; + } + // Classify manually + let mut dets = Vec::new(); + for det in &detectors { + if !g.label.commutes_with(&det.stabilizer) { + dets.push(det.id); + } + } + if dets.is_empty() { + continue; + } + *h_events + .entry(dets) + .or_default() + .entry(g.label.clone()) + .or_insert(0.0) += g.coeff; + } + + let mut out = Vec::new(); + for (det_ids, labels) in &h_events { + let num_labels = labels.len(); + let rates: Vec = labels.values().copied().collect(); + let num_groups = rates.iter().filter(|&&r| r.abs() > 1e-15).count(); + let max_combined = rates.iter().map(|r| r.abs()).fold(0.0_f64, f64::max); + out.push((det_ids.clone(), num_labels, num_groups, rates, max_combined)); + } + Ok(out) +} + +/// Compute per-detector marginals using ALL generators that flip each detector. +/// +/// Unlike the DEM-based approach (which groups by event then sums), this pools +/// all H generators for each detector into a single quadratic form with beta +/// cross-terms. This captures cross-event interference that the per-event +/// computation misses. +/// +/// Returns list of (detector_id, probability). +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0, h_formula="taylor"))] +pub fn eeg_per_detector( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + h_formula: &str, +) -> PyResult> { + let noise = NoiseModel { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(tick_circuit)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let result = circuit::analyze_expanded(&expanded.gates, &noise); + let (detectors, _observables) = extract_detectors_expanded(tick_circuit, &expanded)?; + + let expanded_pre_readout = exclude_final_mz(&expanded.gates); + let stab_group = pecos_eeg::stabilizer::StabilizerGroup::from_circuit( + &expanded_pre_readout, + expanded.num_qubits, + ); + + let formula = parse_h_formula(h_formula)?; + + // Collect all H generators with their BCH-combined rates per label + let mut h_by_label: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for g in &result.generators { + if g.eeg_type == pecos_eeg::eeg::EegType::H { + *h_by_label.entry(g.label.clone()).or_insert(0.0) += g.coeff; + } + } + let h_labels: Vec<(Bm, f64)> = h_by_label + .into_iter() + .filter(|(_, c)| c.abs() > 1e-20) + .collect(); + + // Also collect S generators + let mut s_by_label: std::collections::BTreeMap = std::collections::BTreeMap::new(); + for g in &result.generators { + if g.eeg_type == pecos_eeg::eeg::EegType::S { + *s_by_label.entry(g.label.clone()).or_insert(0.0) += g.coeff; + } + } + + let mut results = Vec::new(); + + for det in &detectors { + // Find all H generators that anticommute with this detector + let det_h: Vec<(usize, f64)> = h_labels + .iter() + .enumerate() + .filter(|(_, (label, _))| !label.commutes_with(&det.stabilizer)) + .map(|(i, (_, c))| (i, *c)) + .collect(); + + // Find S generators that anticommute + let s_sum: f64 = s_by_label + .iter() + .filter(|(label, _)| !label.commutes_with(&det.stabilizer)) + .map(|(_, c)| c) + .sum(); + + // S contribution + let p_s = if s_sum.abs() > 1e-20 { + (1.0 - (2.0 * s_sum).exp()) / 2.0 + } else { + 0.0 + }; + + // H contribution: quadratic form with beta + let h_prob = match formula { + pecos_eeg::dem_mapping::HFormula::Taylor + | pecos_eeg::dem_mapping::HFormula::SinSquared + | pecos_eeg::dem_mapping::HFormula::ExactSubset => { + let mut total = 0.0_f64; + for (j, &(idx_j, h_j)) in det_h.iter().enumerate() { + // Diagonal + total += h_j * h_j; + // Off-diagonal with beta + for &(idx_k, h_k) in det_h.iter().skip(j + 1) { + let q_j = &h_labels[idx_j].0; + let q_k = &h_labels[idx_k].0; + + if !q_j.commutes_with(q_k) { + continue; + } + let product = q_j.multiply(q_k); + if product.is_identity() { + total += 2.0 * h_j * h_k; + continue; + } + match stab_group.is_stabilizer(&product) { + Some(true) => { + total += 2.0 * h_j * h_k; + } + Some(false) => { + total -= 2.0 * h_j * h_k; + } + None => {} + } + } + } + let total = total.max(0.0); + match formula { + pecos_eeg::dem_mapping::HFormula::SinSquared => total.sqrt().sin().powi(2), + _ => total, + } + } + pecos_eeg::dem_mapping::HFormula::ExactCommuting => { + // Product formula over all generators for this detector + let mut prod_re = 1.0_f64; + let mut prod_im = 0.0_f64; + for &(idx_j, h_j) in &det_h { + let label = &h_labels[idx_j].0; + let p_stab = if label.is_identity() { + Some(true) + } else { + stab_group.is_stabilizer(label) + }; + + let (f_re, f_im) = if let Some(sign) = p_stab { + let s = if sign { 1.0 } else { -1.0 }; + let angle = 2.0 * s * h_j; + (angle.cos(), angle.sin()) + } else { + let dp = det.stabilizer.multiply(label); + let dp_stab = if dp.is_identity() { + Some(true) + } else { + stab_group.is_stabilizer(&dp) + }; + if let Some(sign) = dp_stab { + let s = if sign { 1.0 } else { -1.0 }; + let angle = -2.0 * s * h_j; + (angle.cos(), angle.sin()) + } else { + ((2.0 * h_j).cos(), 0.0) + } + }; + let new_re = prod_re * f_re - prod_im * f_im; + let new_im = prod_re * f_im + prod_im * f_re; + prod_re = new_re; + prod_im = new_im; + } + (0.5 * (1.0 - prod_re)).max(0.0) + } + }; + + // Combine S and H (independent) + let p = if p_s.abs() > 1e-15 && h_prob > 1e-15 { + p_s + h_prob - 2.0 * p_s * h_prob + } else { + p_s.abs() + h_prob + }; + results.push((det.id, p)); + } + + Ok(results) +} + +/// Compute exact per-detector detection probabilities. +/// +/// Uses backward Heisenberg propagation: walks the detector observable +/// backward through the circuit, splitting at each noise source. Exact +/// for both coherent (idle_rz) and stochastic (depolarizing) noise. +/// +/// This is the most accurate DEM generation method in PECOS. Use it when: +/// - You need exact detection rates under coherent noise +/// - You want to validate the non-EEG DEM builder +/// - You need per-detector probabilities (not a full DEM) +/// +/// For a full DEM (event structure + probabilities), use `eeg_heisenberg_dem`. +/// For fast approximate rates under coherent noise, use `eeg_dem_events`. +/// For depolarizing-only noise, `DemSampler.from_circuit` is faster and exact. +/// +/// Returns: list of (detector_id, probability) for each detector. +/// +/// Example: +/// probs = exact_detection_rates(tc, idle_rz=0.05) +/// probs = exact_detection_rates(tc, idle_rz=0.05, p2=0.01) +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0, prune=1e-12, window=None))] +pub fn exact_detection_rates( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + prune: f64, + #[allow(unused_variables)] window: Option, +) -> PyResult> { + let noise = pecos_eeg::noise::UniformNoise { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(tick_circuit)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let (detectors, _observables) = extract_detectors_expanded(tick_circuit, &expanded)?; + + // Build initial stabilizer group: Z on each original qubit + let init_gates: Vec = (0..expanded.num_original_qubits) + .map(|q| pecos_eeg::expand::make_gate(pecos_core::gate_type::GateType::PZ, &[q])) + .collect(); + let stab = + pecos_eeg::stabilizer::StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + + // Build qubit-to-gate index once, shared across all detector walks. + let gate_index = pecos_eeg::expand::GateIndex::build(&expanded.gates, expanded.num_qubits); + + // Use noise map (with batched S-type) when stochastic noise is present. + // For coherent-only (idle_rz), the bitmap-enhanced linear scan is faster. + let has_stochastic = p1 > 0.0 || p2 > 0.0 || p_meas > 0.0 || p_prep > 0.0; + + let noise_map = if has_stochastic { + Some(pecos_eeg::heisenberg::build_noise_map( + &expanded.gates, + &noise, + &gate_index.expansion_gates, + )) + } else { + None + }; + + // Parallelize across detectors — each walk is independent. + // Uses sparse traversal (heap + gate index) for O(active_gates) instead of O(all_gates). + let results: Vec<(usize, f64)> = detectors + .par_iter() + .map(|det| { + let p = pecos_eeg::heisenberg::heisenberg_sparse( + &expanded.gates, + &det.stabilizer, + &noise, + &stab, + prune, + &gate_index, + noise_map.as_deref(), + ); + (det.id, p) + }) + .collect(); + + Ok(results) +} + +/// Compute exact pairwise detection rates via backward Heisenberg walk. +/// +/// For each pair of detectors (i, j), computes P(Di AND Dj both fire) +/// using the identity: +/// P(Di=1, Dj=1) = (P(Di) + P(Dj) - P_walk(Si*Sj)) / 2 +/// where P_walk(Si*Sj) is a Heisenberg walk with the product stabilizer. +/// +/// Returns a list of ((det_i, det_j), joint_probability) tuples. +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0, prune=1e-12))] +pub fn exact_pairwise_rates( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + prune: f64, +) -> PyResult> { + let noise = pecos_eeg::noise::UniformNoise { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(tick_circuit)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let (detectors, _observables) = extract_detectors_expanded(tick_circuit, &expanded)?; + + let init_gates: Vec = (0..expanded.num_original_qubits) + .map(|q| pecos_eeg::expand::make_gate(pecos_core::gate_type::GateType::PZ, &[q])) + .collect(); + let stab = + pecos_eeg::stabilizer::StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + + let gate_index = pecos_eeg::expand::GateIndex::build(&expanded.gates, expanded.num_qubits); + let has_stochastic = p1 > 0.0 || p2 > 0.0 || p_meas > 0.0 || p_prep > 0.0; + let noise_map = if has_stochastic { + Some(pecos_eeg::heisenberg::build_noise_map( + &expanded.gates, + &noise, + &gate_index.expansion_gates, + )) + } else { + None + }; + + let walk = |stab_bm: &Bm| -> f64 { + pecos_eeg::heisenberg::heisenberg_sparse( + &expanded.gates, + stab_bm, + &noise, + &stab, + prune, + &gate_index, + noise_map.as_deref(), + ) + }; + + // Marginals + let marginals: Vec = detectors.iter().map(|d| walk(&d.stabilizer)).collect(); + + // Pairwise: P(Di AND Dj) = (P(Di) + P(Dj) - P_walk(Si*Sj)) / 2 + let pairs: Vec<(usize, usize)> = (0..detectors.len()) + .flat_map(|i| ((i + 1)..detectors.len()).map(move |j| (i, j))) + .collect(); + + let results: Vec<((usize, usize), f64)> = pairs + .par_iter() + .map(|&(i, j)| { + let product = detectors[i].stabilizer.multiply(&detectors[j].stabilizer); + let p_product = walk(&product); + let p_joint = (marginals[i] + marginals[j] - p_product) / 2.0; + ((detectors[i].id, detectors[j].id), p_joint.max(0.0)) + }) + .collect(); + + Ok(results) +} + +/// Build a coherent DEM with exact Heisenberg marginals. +/// +/// Combines backward mechanism extraction (correct structure) with +/// Heisenberg-exact per-detector rates (correct probabilities). +/// Fits mechanism probabilities to match the exact marginals. +/// +/// Returns the DEM as a Stim-format string. +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0, prune=1e-12))] +pub fn coherent_dem_exact( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + prune: f64, +) -> PyResult { + let noise = pecos_eeg::noise::UniformNoise { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(tick_circuit)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let (detectors, observables) = extract_detectors_expanded(tick_circuit, &expanded)?; + let gate_index = pecos_eeg::expand::GateIndex::build(&expanded.gates, expanded.num_qubits); + + // Compute Heisenberg exact marginals + let init_gates: Vec = (0..expanded.num_original_qubits) + .map(|q| pecos_eeg::expand::make_gate(pecos_core::gate_type::GateType::PZ, &[q])) + .collect(); + let stab = + pecos_eeg::stabilizer::StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + let has_stochastic = p1 > 0.0 || p2 > 0.0 || p_meas > 0.0 || p_prep > 0.0; + let noise_map = if has_stochastic { + Some(pecos_eeg::heisenberg::build_noise_map( + &expanded.gates, + &noise, + &gate_index.expansion_gates, + )) + } else { + None + }; + + let walk = |stab_bm: &Bm| -> f64 { + pecos_eeg::heisenberg::heisenberg_sparse( + &expanded.gates, + stab_bm, + &noise, + &stab, + prune, + &gate_index, + noise_map.as_deref(), + ) + }; + + let mut marginals = vec![0.0_f64; detectors.iter().map(|d| d.id + 1).max().unwrap_or(0)]; + for det in &detectors { + let p = walk(&det.stabilizer); + if det.id < marginals.len() { + marginals[det.id] = p; + } + } + + // Compute pairwise rates via product stabilizer walks + let mut pairwise: Vec<((usize, usize), f64)> = Vec::new(); + for i in 0..detectors.len() { + for j in (i + 1)..detectors.len() { + let product = detectors[i].stabilizer.multiply(&detectors[j].stabilizer); + let p_product = walk(&product); + let p_joint = + (marginals[detectors[i].id] + marginals[detectors[j].id] - p_product) / 2.0; + if p_joint > 1e-10 { + pairwise.push(((detectors[i].id, detectors[j].id), p_joint.max(0.0))); + } + } + } + + // Build DEM with exact marginals + pairwise + let entries = pecos_eeg::coherent_dem::build_coherent_dem_exact( + &expanded.gates, + &noise, + &detectors, + &observables, + &gate_index.expansion_gates, + &marginals, + Some(&pairwise), + ); + + Ok(pecos_eeg::dem_mapping::format_dem(&entries)) +} + +/// Build coherent DEM with proper X/Z decomposition for MWPM decoders. +/// +/// Returns (raw_dem, decomposed_dem) where the decomposed version uses +/// Pauli provenance to split hyperedges into X ^ Z components. +/// Probabilities are fitted to Heisenberg-exact marginals via L-BFGS. +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0, prune=1e-12))] +pub fn coherent_dem_decomposed( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + prune: f64, +) -> PyResult<(String, String)> { + let noise = pecos_eeg::noise::UniformNoise { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(tick_circuit)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let (detectors, observables) = extract_detectors_expanded(tick_circuit, &expanded)?; + let gate_index = pecos_eeg::expand::GateIndex::build(&expanded.gates, expanded.num_qubits); + + // Compute Heisenberg-exact marginals for probability fitting + let init_gates: Vec = (0..expanded.num_original_qubits) + .map(|q| pecos_eeg::expand::make_gate(pecos_core::gate_type::GateType::PZ, &[q])) + .collect(); + let stab = + pecos_eeg::stabilizer::StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + let has_stochastic = p1 > 0.0 || p2 > 0.0 || p_meas > 0.0 || p_prep > 0.0; + let noise_map = if has_stochastic { + Some(pecos_eeg::heisenberg::build_noise_map( + &expanded.gates, + &noise, + &gate_index.expansion_gates, + )) + } else { + None + }; + + let walk = |stab_bm: &Bm| -> f64 { + pecos_eeg::heisenberg::heisenberg_sparse( + &expanded.gates, + stab_bm, + &noise, + &stab, + prune, + &gate_index, + noise_map.as_deref(), + ) + }; + + let mut marginals = vec![0.0_f64; detectors.iter().map(|d| d.id + 1).max().unwrap_or(0)]; + for det in &detectors { + let p = walk(&det.stabilizer); + if det.id < marginals.len() { + marginals[det.id] = p; + } + } + + // Pairwise rates for better fitting + let mut pairwise: Vec<((usize, usize), f64)> = Vec::new(); + for i in 0..detectors.len() { + for j in (i + 1)..detectors.len() { + let product = detectors[i].stabilizer.multiply(&detectors[j].stabilizer); + let p_product = walk(&product); + let p_joint = + (marginals[detectors[i].id] + marginals[detectors[j].id] - p_product) / 2.0; + if p_joint > 1e-10 { + pairwise.push(((detectors[i].id, detectors[j].id), p_joint.max(0.0))); + } + } + } + + // Build decomposable entries with exact-fitted probabilities + let entries = pecos_eeg::coherent_dem::build_coherent_dem_exact_decomposable( + &expanded.gates, + &noise, + &detectors, + &observables, + &gate_index.expansion_gates, + &marginals, + Some(&pairwise), + ); + + let raw = pecos_eeg::dem_mapping::format_dem( + &entries + .iter() + .map(|e| pecos_eeg::dem_mapping::DemEntry { + event: e.event.clone(), + probability: e.probability, + }) + .collect::>(), + ); + let decomposed = pecos_eeg::dem_mapping::format_dem_decomposed(&entries); + + Ok((raw, decomposed)) +} + +/// Compute exact k-body detector correlation table from Heisenberg walks. +/// +/// Returns exact joint detection probabilities for all detector subsets +/// up to `max_order`. No DEM approximation — captures all coherent +/// interference. Useful for decoders that can consume raw correlation data. +/// +/// Returns a list of (detector_indices, probability) pairs. +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0, max_order=2, prune=1e-12))] +pub fn exact_correlation_table( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + max_order: usize, + prune: f64, +) -> PyResult, f64)>> { + let noise = pecos_eeg::noise::UniformNoise { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(tick_circuit)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let (detectors, observables) = extract_detectors_expanded(tick_circuit, &expanded)?; + + let init_gates: Vec = (0..expanded.num_original_qubits) + .map(|q| pecos_eeg::expand::make_gate(pecos_core::gate_type::GateType::PZ, &[q])) + .collect(); + let stab = + pecos_eeg::stabilizer::StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + + let table = pecos_eeg::correlation_table::compute_correlation_table(CorrelationTableInput { + gates: &expanded.gates, + noise: &noise, + detectors: &detectors, + observables: &observables, + initial_stab: &stab, + num_qubits: expanded.num_qubits, + max_order, + prune_threshold: prune, + }); + + // String labels: "D0", "D1", "L0" — consistent with Stim DEM format. + let mut result: Vec<(Vec, f64)> = table + .rates + .into_iter() + .map(|(k, v)| (k.into_iter().map(|d| format!("D{d}")).collect(), v)) + .collect(); + + // Observable correlations with string labels + for ((det_ids, obs_id), prob) in table.observable_rates { + let mut labels: Vec = det_ids.into_iter().map(|d| format!("D{d}")).collect(); + labels.push(format!("L{obs_id}")); + result.push((labels, prob)); + } + + Ok(result) +} + +/// Build a graphlike DEM from exact Heisenberg correlation tables. +/// +/// Bypasses the DEM independent error model entirely. Edge weights come +/// directly from exact pairwise correlations (including all coherent +/// interference effects). For MWPM decoders. +/// +/// Returns a DEM string suitable for pymatching/fusion_blossom. +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0, max_order=2, prune=1e-12))] +pub fn correlation_matching_dem( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + max_order: usize, + prune: f64, +) -> PyResult { + let noise = pecos_eeg::noise::UniformNoise { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(tick_circuit)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let (detectors, observables) = extract_detectors_expanded(tick_circuit, &expanded)?; + + let init_gates: Vec = (0..expanded.num_original_qubits) + .map(|q| pecos_eeg::expand::make_gate(pecos_core::gate_type::GateType::PZ, &[q])) + .collect(); + let stab = + pecos_eeg::stabilizer::StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + + let table = pecos_eeg::correlation_table::compute_correlation_table(CorrelationTableInput { + gates: &expanded.gates, + noise: &noise, + detectors: &detectors, + observables: &observables, + initial_stab: &stab, + num_qubits: expanded.num_qubits, + max_order, + prune_threshold: prune, + }); + + Ok(table.to_matching_dem()) +} + +/// Compress mid-round noise to round boundaries (optional optimization). +/// +/// Propagates gate noise forward to round boundaries, accumulating +/// faults with the same effective Pauli label. Measurement and prep +/// noise kept at original positions. Returns compression statistics. +/// +/// For stochastic Pauli noise: exact. For coherent: within-round exact. +/// +/// Returns (original_count, compressed_count, boundary_noise_labels). +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0))] +pub fn compress_noise( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, +) -> PyResult<(usize, usize)> { + let noise = pecos_eeg::noise::UniformNoise { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(tick_circuit)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let gate_index = pecos_eeg::expand::GateIndex::build(&expanded.gates, expanded.num_qubits); + + let result = pecos_eeg::noise_compression::compress_noise_to_boundaries( + &expanded.gates, + &noise, + &gate_index.expansion_gates, + ); + + Ok((result.original_count, result.compressed_count)) +} + +/// Complete noise characterization: correlations + mechanisms + DEM. +/// +/// Returns a JSON string containing: +/// - Exact k-body detector correlations (from Heisenberg walks) +/// - Detector-observable cross-correlations +/// - Mechanism catalog with fitted probabilities +/// - DEM string for standard decoders +/// +/// This is the unified output that captures everything a decoder needs. +#[pyfunction] +#[pyo3(signature = (tick_circuit, idle_rz=0.0, p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0, max_order=2, prune=1e-12, compress=false))] +pub fn noise_characterization( + tick_circuit: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + max_order: usize, + prune: f64, + compress: bool, +) -> PyResult<(String, String, String)> { + let base_noise = pecos_eeg::noise::UniformNoise { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(tick_circuit)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let (detectors, observables) = extract_detectors_expanded(tick_circuit, &expanded)?; + + let init_gates: Vec = (0..expanded.num_original_qubits) + .map(|q| pecos_eeg::expand::make_gate(pecos_core::gate_type::GateType::PZ, &[q])) + .collect(); + let stab = + pecos_eeg::stabilizer::StabilizerGroup::from_circuit(&init_gates, expanded.num_qubits); + + // For compressed mode: use original noise for Heisenberg targets (exact), + // compressed noise for mechanism structure (fast). + let structure_noise: Option> = if compress { + let gate_index = pecos_eeg::expand::GateIndex::build(&expanded.gates, expanded.num_qubits); + let compressed = pecos_eeg::noise_compression::compress_noise_to_boundaries( + &expanded.gates, + &base_noise, + &gate_index.expansion_gates, + ); + Some(Box::new( + pecos_eeg::noise_compression::CompressedNoiseSpec::from_compressed(&compressed), + )) + } else { + None + }; + + let det_meas_ids = extract_meas_id_defs(tick_circuit, "detectors")?; + let obs_meas_ids = extract_meas_id_defs(tick_circuit, "observables")?; + + let nc = pecos_eeg::noise_characterization::NoiseCharacterization::build( + NoiseCharacterizationInput { + gates: &expanded.gates, + noise: &base_noise, + structure_noise: structure_noise.as_deref(), + detectors: &detectors, + observables: &observables, + initial_stab: &stab, + num_qubits: expanded.num_qubits, + max_order, + prune_threshold: prune, + detector_meas_ids: &det_meas_ids, + observable_meas_ids: &obs_meas_ids, + }, + ); + + Ok(( + nc.to_json(), + nc.to_dem_string(), + nc.to_dem_string_decomposed(), + )) +} + +// -- Internal -- + +fn parse_h_formula(s: &str) -> PyResult { + match s { + "taylor" => Ok(pecos_eeg::dem_mapping::HFormula::Taylor), + "sin_squared" => Ok(pecos_eeg::dem_mapping::HFormula::SinSquared), + "exact_commuting" => Ok(pecos_eeg::dem_mapping::HFormula::ExactCommuting), + "exact_subset" => Ok(pecos_eeg::dem_mapping::HFormula::ExactSubset), + _ => Err(pyo3::exceptions::PyValueError::new_err(format!( + "Unknown h_formula '{s}'. Use 'taylor', 'sin_squared', 'exact_commuting', or 'exact_subset'." + ))), + } +} + +fn run_eeg( + py_tc: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + h_formula: &str, + bch_order: u32, +) -> PyResult> { + let noise = NoiseModel { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(py_tc)?; + + // Step 1: Expand circuit (defer measurements) + let expanded = pecos_eeg::expand::expand_circuit(&gates); + + // Step 2: Propagate through expanded circuit + let result = pecos_eeg::circuit::analyze_expanded(&expanded.gates, &noise); + + // Step 3: Build detectors using expanded circuit mapping + let (detectors, observables) = extract_detectors_expanded(py_tc, &expanded)?; + + // Step 4: Compute stabilizer group from EXPANDED circuit (pre-readout). + // Use expanded frame directly — no lossy original-frame mapping. + // Strip trailing deferred MZ(aux) from the expanded circuit. + let expanded_pre_readout = exclude_final_mz(&expanded.gates); + let stab_group = pecos_eeg::stabilizer::StabilizerGroup::from_circuit( + &expanded_pre_readout, + expanded.num_qubits, + ); + + // Step 5: Build DEM (stabilizer check in expanded frame) + let config = pecos_eeg::dem_mapping::EegConfig { + h_formula: parse_h_formula(h_formula)?, + bch_order: if bch_order >= 2 { + pecos_eeg::dem_mapping::BchOrder::Second + } else { + pecos_eeg::dem_mapping::BchOrder::First + }, + }; + Ok(pecos_eeg::dem_mapping::build_dem_configured( + &result.generators, + &detectors, + &observables, + Some(&stab_group), + &config, + )) +} + +fn run_eeg_decomposable( + py_tc: &Bound<'_, PyAny>, + idle_rz: f64, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + h_formula: &str, + bch_order: u32, +) -> PyResult<( + Vec, + Vec, +)> { + let noise = NoiseModel { + idle_rz, + p1, + p2, + p_meas, + p_prep, + }; + let gates = extract_gates(py_tc)?; + let expanded = pecos_eeg::expand::expand_circuit(&gates); + let result = pecos_eeg::circuit::analyze_expanded(&expanded.gates, &noise); + let (detectors, observables) = extract_detectors_expanded(py_tc, &expanded)?; + let expanded_pre_readout = exclude_final_mz(&expanded.gates); + let stab_group = pecos_eeg::stabilizer::StabilizerGroup::from_circuit( + &expanded_pre_readout, + expanded.num_qubits, + ); + let config = pecos_eeg::dem_mapping::EegConfig { + h_formula: parse_h_formula(h_formula)?, + bch_order: if bch_order >= 2 { + pecos_eeg::dem_mapping::BchOrder::Second + } else { + pecos_eeg::dem_mapping::BchOrder::First + }, + }; + let raw = pecos_eeg::dem_mapping::build_dem_configured( + &result.generators, + &detectors, + &observables, + Some(&stab_group), + &config, + ); + let decomposable = pecos_eeg::dem_mapping::build_dem_decomposable( + &result.generators, + &detectors, + &observables, + Some(&stab_group), + &config, + ); + Ok((raw, decomposable)) +} + +/// Strip all trailing MZ from expanded circuit (deferred measurements). +fn exclude_final_mz(gates: &[Gate]) -> Vec { + let last_non_mz = gates + .iter() + .rposition(|g| g.gate_type != pecos_core::gate_type::GateType::MZ); + match last_non_mz { + Some(idx) => gates[..=idx].to_vec(), + None => Vec::new(), + } +} + +fn extract_gates(py_tc: &Bound<'_, PyAny>) -> PyResult> { + let num_ticks: usize = py_tc.call_method0("num_ticks")?.extract()?; + let mut gates = Vec::new(); + + for tick_idx in 0..num_ticks { + let py_tick = py_tc.call_method1("get_tick", (tick_idx,))?; + let py_gates = py_tick.call_method0("gate_batches")?; + let gate_list: Vec> = py_gates.extract()?; + + // Collect all gates in this tick first, then emit them. + // This preserves the simultaneity of gates within a tick: + // all Clifford gates in the tick execute "at once", and noise + // is injected after the entire tick (not between gates). + let mut tick_gates = Vec::new(); + + for gate in &gate_list { + let name: String = gate.getattr("gate_type")?.getattr("name")?.extract()?; + let qubits: Vec = gate.getattr("qubits")?.extract()?; + + match name.as_str() { + "CX" | "CY" | "CZ" | "SWAP" | "SZZ" | "SZZdg" | "SXX" | "SXXdg" | "SYY" + | "SYYdg" => { + // Split multi-pair 2q gates into individual pairs + let gt = match name.as_str() { + "CX" => pecos_core::gate_type::GateType::CX, + "CY" => pecos_core::gate_type::GateType::CY, + "CZ" => pecos_core::gate_type::GateType::CZ, + "SWAP" => pecos_core::gate_type::GateType::SWAP, + "SZZ" => pecos_core::gate_type::GateType::SZZ, + "SZZdg" => pecos_core::gate_type::GateType::SZZdg, + "SXX" => pecos_core::gate_type::GateType::SXX, + "SXXdg" => pecos_core::gate_type::GateType::SXXdg, + "SYY" => pecos_core::gate_type::GateType::SYY, + _ => pecos_core::gate_type::GateType::SYYdg, + }; + for pair in qubits.chunks(2) { + if pair.len() == 2 { + tick_gates.push(Gate { + gate_type: gt, + qubits: pair.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: GateMeasIds::new(), + channel: None, + }); + } + } + } + // Single-qubit gates: split multi-qubit into individual per-qubit gates + "H" | "X" | "Y" | "Z" | "SZ" | "SZdg" | "SX" | "SXdg" | "SY" | "SYdg" | "F" + | "Fdg" => { + let gt = match name.as_str() { + "H" => pecos_core::gate_type::GateType::H, + "X" => pecos_core::gate_type::GateType::X, + "Y" => pecos_core::gate_type::GateType::Y, + "Z" => pecos_core::gate_type::GateType::Z, + "SZ" => pecos_core::gate_type::GateType::SZ, + "SZdg" => pecos_core::gate_type::GateType::SZdg, + "SX" => pecos_core::gate_type::GateType::SX, + "SXdg" => pecos_core::gate_type::GateType::SXdg, + "SY" => pecos_core::gate_type::GateType::SY, + "F" => pecos_core::gate_type::GateType::F, + "Fdg" => pecos_core::gate_type::GateType::Fdg, + _ => pecos_core::gate_type::GateType::SYdg, + }; + for &q in &qubits { + tick_gates.push(Gate { + gate_type: gt, + qubits: std::iter::once(QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: GateMeasIds::new(), + channel: None, + }); + } + } + // PZ/QAlloc: split multi-qubit into per-qubit + "QAlloc" | "PZ" => { + for &q in &qubits { + tick_gates.push(Gate { + gate_type: pecos_core::gate_type::GateType::PZ, + qubits: std::iter::once(QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: GateMeasIds::new(), + channel: None, + }); + } + } + // MZ: keep multi-qubit (expansion handles per-qubit) + _ => { + let gt = match name.as_str() { + "MZ" | "MeasureFree" => pecos_core::gate_type::GateType::MZ, + "RZ" => pecos_core::gate_type::GateType::RZ, + "Idle" | "I" => pecos_core::gate_type::GateType::Idle, + other => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "EEG extract_gates: unsupported gate type {other:?}" + ))); + } + }; + let mut g = Gate { + gate_type: gt, + qubits: qubits.iter().map(|&q| QubitId(q)).collect(), + angles: GateAngles::new(), + params: GateParams::new(), + meas_ids: GateMeasIds::new(), + channel: None, + }; + if gt == pecos_core::gate_type::GateType::RZ + && let Ok(angles) = gate.getattr("angles")?.extract::>() + && let Some(&a) = angles.first() + { + g.angles.push(Angle64::from_radians(a)); + } + tick_gates.push(g); + } + } + } + + // Emit all gates from this tick. Gates within a tick are simultaneous, + // so noise injected after the last gate correctly sees all tick gates + // as already applied. + gates.extend(tick_gates); + } + + Ok(gates) +} + +fn measurement_record_index(record: i32, num_measurements: usize) -> Option { + let idx = if record < 0 { + i32::try_from(num_measurements).ok()?.checked_add(record)? + } else { + record + }; + usize::try_from(idx) + .ok() + .filter(|&idx| idx < num_measurements) +} + +fn extract_detectors_expanded( + py_tc: &Bound<'_, PyAny>, + expanded: &pecos_eeg::expand::ExpandedCircuit, +) -> PyResult<(Vec, Vec)> { + // In the expanded circuit, each measurement record k maps to a + // Z-measurement on auxiliary qubit expanded.measurement_qubit[k]. + // + // A detector defined by records {r1, r2, ...} has stabilizer + // Z_{aux_r1} * Z_{aux_r2} * ... in the expanded circuit. + let num_meas = expanded.measurement_qubit.len(); + + let mut detectors = Vec::new(); + let mut observables = Vec::new(); + + // Parse detector JSON from metadata + if let Ok(det_json_str) = py_tc.call_method1("get_meta", ("detectors",)) + && let Ok(det_json) = det_json_str.extract::() + && let Ok(det_list) = serde_json_parse_detectors(&det_json) + { + for (id, records) in det_list { + let mut bm = Bm::default(); + for &rec in &records { + if let Some(abs_idx) = measurement_record_index(rec, num_meas) { + // Map to AUXILIARY qubit in expanded circuit + let aux_qubit = expanded.measurement_qubit[abs_idx]; + bm.z_bits.xor_bit(aux_qubit); + } + } + detectors.push(Detector { id, stabilizer: bm }); + } + } + + // Parse observable JSON from metadata + if let Ok(obs_json_str) = py_tc.call_method1("get_meta", ("observables",)) + && let Ok(obs_json) = obs_json_str.extract::() + && let Ok(obs_list) = serde_json_parseobservables(&obs_json) + { + for (id, records) in obs_list { + let mut bm = Bm::default(); + for &rec in &records { + if let Some(abs_idx) = measurement_record_index(rec, num_meas) { + let aux_qubit = expanded.measurement_qubit[abs_idx]; + bm.z_bits.xor_bit(aux_qubit); + } + } + observables.push(Observable { id, pauli: bm }); + } + } + + Ok((detectors, observables)) +} + +/// Minimal JSON parser for detector definitions (avoids serde dependency). +/// Parses [{"id": N, "records": [R1, R2, ...], ...}, ...] +fn serde_json_parse_detectors(json: &str) -> Result)>, String> { + // Simple approach: find "id" and "records" fields via string scanning + let mut result = Vec::new(); + let mut pos = 0; + while let Some(start) = json[pos..].find('{') { + let start = pos + start; + let end = json[start..] + .find('}') + .map(|e| start + e + 1) + .ok_or_else(|| "Unmatched brace".to_string())?; + let entry = &json[start..end]; + + let id = extract_json_int(entry, "\"id\"") + .and_then(|value| usize::try_from(value).ok()) + .unwrap_or(result.len()); + let records = extract_json_int_array(entry, "\"records\"").unwrap_or_default(); + + result.push((id, records)); + pos = end; + } + Ok(result) +} + +fn serde_json_parseobservables(json: &str) -> Result)>, String> { + serde_json_parse_detectors(json) // Same format +} + +/// Extract MeasId definitions from circuit metadata JSON. +fn extract_meas_id_defs( + py_tc: &Bound<'_, pyo3::PyAny>, + key: &str, // "detectors" or "observables" +) -> PyResult> { + let mut result = Vec::new(); + if let Ok(json_str) = py_tc.call_method1("get_meta", (key,)) + && let Ok(s) = json_str.extract::() + { + // Parse JSON: each item has id, meas_ids (optional), records + let items = parse_json_items(&s); + for (idx, (records, meas_ids)) in items.iter().enumerate() { + result.push((idx, meas_ids.clone(), records.clone())); + } + } + Ok(result) +} + +/// Parse JSON array items, extracting records and meas_ids fields. +fn parse_json_items(json: &str) -> Vec<(Vec, Vec)> { + let mut result = Vec::new(); + // Split by "records" occurrences + let trimmed = json.trim(); + if !trimmed.starts_with('[') { + return result; + } + + // Simple state machine: find each {...} block and extract fields + let mut depth = 0; + let mut block_start = None; + for (i, ch) in trimmed.char_indices() { + match ch { + '{' => { + if depth == 1 { + block_start = Some(i); + } + depth += 1; + } + '}' => { + depth -= 1; + if depth == 1 + && let Some(start) = block_start + { + let block = &trimmed[start..=i]; + let records = extract_json_int_array(block, "records").unwrap_or_default(); + let meas_ids = extract_json_int_array(block, "meas_ids") + .map(|v| { + v.into_iter() + .filter_map(|x| usize::try_from(x).ok()) + .collect() + }) + .unwrap_or_default(); + result.push((records, meas_ids)); + } + } + '[' if depth == 0 => { + depth = 1; + } + ']' if depth == 1 => { + break; + } + _ => {} + } + } + result +} + +fn extract_json_int(s: &str, key: &str) -> Option { + let key_pos = s.find(key)?; + let after_key = &s[key_pos + key.len()..]; + let colon = after_key.find(':')?; + let value_str = after_key[colon + 1..].trim(); + // Read digits (possibly with minus) + let end = value_str + .find(|c: char| !c.is_ascii_digit() && c != '-') + .unwrap_or(value_str.len()); + value_str[..end].trim().parse().ok() +} + +fn extract_json_int_array(s: &str, key: &str) -> Option> { + let key_pos = s.find(key)?; + let after_key = &s[key_pos + key.len()..]; + let bracket_start = after_key.find('[')?; + let bracket_end = after_key[bracket_start..].find(']')? + bracket_start; + let array_str = &after_key[bracket_start + 1..bracket_end]; + let values: Vec = array_str + .split(',') + .filter_map(|v| v.trim().parse().ok()) + .collect(); + Some(values) +} diff --git a/python/pecos-rslib-exp/src/lib.rs b/python/pecos-rslib-exp/src/lib.rs index 382b2f4d3..bf1a8fe67 100644 --- a/python/pecos-rslib-exp/src/lib.rs +++ b/python/pecos-rslib-exp/src/lib.rs @@ -10,13 +10,31 @@ // express or implied. See the License for the specific language governing permissions and // limitations under the License. +// Experimental PyO3 binding signatures are constrained by Python-callable APIs +// and generated method wrappers. Python docstrings also contain Python snippets +// that Clippy's Rust-doc Markdown lint misclassifies. Keep this list limited to +// binding/docs shape lints. +#![allow( + clippy::doc_markdown, + clippy::missing_errors_doc, + clippy::missing_panics_doc, + clippy::needless_pass_by_value, + clippy::too_many_arguments, + clippy::unnecessary_wraps, + clippy::unused_self +)] + //! Python bindings for experimental PECOS simulators. //! //! Exposes `StabMps` (stabilizer + MPS hybrid) and `Mast` (magic state //! injection) from `pecos-stab-tn` via `PyO3`. +mod coherent_idle_channel; +mod eeg_bindings; mod mast_bindings; +mod sim_neo_bindings; mod stab_mps_bindings; +pub mod stabmps_builder; use pecos_core::Angle64; use pyo3::prelude::*; @@ -48,5 +66,38 @@ pub(crate) fn extract_angle( fn pecos_rslib_exp(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sim_neo_bindings::py_sim_neo, 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::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sim_neo_bindings::statevec, m)?)?; + m.add_function(wrap_pyfunction!(sim_neo_bindings::stabilizer, m)?)?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sim_neo_bindings::meas_sampling, m)?)?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_class::()?; + m.add_function(wrap_pyfunction!(sim_neo_bindings::fault_catalog, m)?)?; + // DEM generation functions + m.add_function(wrap_pyfunction!(eeg_bindings::exact_detection_rates, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::exact_pairwise_rates, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::coherent_dem_exact, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::coherent_dem_decomposed, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::exact_correlation_table, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::correlation_matching_dem, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::noise_characterization, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::compress_noise, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::perturbative_dem, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::perturbative_dem_events, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::eeg_summary, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::eeg_event_diagnostics, m)?)?; + m.add_function(wrap_pyfunction!(eeg_bindings::eeg_per_detector, m)?)?; Ok(()) } diff --git a/python/pecos-rslib-exp/src/mast_bindings.rs b/python/pecos-rslib-exp/src/mast_bindings.rs index 5e4ec8f1e..6f8c32e54 100644 --- a/python/pecos-rslib-exp/src/mast_bindings.rs +++ b/python/pecos-rslib-exp/src/mast_bindings.rs @@ -120,11 +120,35 @@ impl PyMast { self.inner.h(q); Ok(None) } + "F" | "F1" => { + self.inner.f(q); + Ok(None) + } + "Fdg" | "F1d" | "F1dg" => { + self.inner.fdg(q); + Ok(None) + } + "SX" | "SqrtX" | "Q" => { + self.inner.sx(q); + Ok(None) + } + "SXdg" | "SqrtXdg" | "SqrtXd" | "Qd" => { + self.inner.sxdg(q); + Ok(None) + } + "SY" | "SqrtY" | "R" => { + self.inner.sy(q); + Ok(None) + } + "SYdg" | "SqrtYdg" | "SqrtYd" | "Rd" => { + self.inner.sydg(q); + Ok(None) + } "S" | "SZ" | "SqrtZ" => { self.inner.sz(q); Ok(None) } - "Sd" | "SZdg" | "SqrtZdg" => { + "Sd" | "SZdg" | "SqrtZdg" | "SqrtZd" => { self.inner.szdg(q); Ok(None) } @@ -211,6 +235,34 @@ impl PyMast { self.inner.cz(pair); Ok(None) } + "SXX" => { + self.inner.sxx(pair); + Ok(None) + } + "SXXdg" => { + self.inner.sxxdg(pair); + Ok(None) + } + "SYY" => { + self.inner.syy(pair); + Ok(None) + } + "SYYdg" => { + self.inner.syydg(pair); + Ok(None) + } + "SZZ" => { + self.inner.szz(pair); + Ok(None) + } + "SZZdg" => { + self.inner.szzdg(pair); + Ok(None) + } + "SWAP" => { + self.inner.swap(pair); + Ok(None) + } "RZZ" => { let angle = crate::extract_angle(params, "RZZ")?; self.inner.rzz(angle, pair); diff --git a/python/pecos-rslib-exp/src/sim_neo_bindings.rs b/python/pecos-rslib-exp/src/sim_neo_bindings.rs new file mode 100644 index 000000000..57fd31c7f --- /dev/null +++ b/python/pecos-rslib-exp/src/sim_neo_bindings.rs @@ -0,0 +1,1817 @@ +// 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. + +//! Python bindings for `sim_neo` with builder pattern. +//! +//! Mirrors the Rust-side API: +//! ```python +//! 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) +//! .seed(42) +//! .run()) +//! ``` + +use pecos_core::{Angle64, ChannelExpr, Gate, Pauli, PauliString, QuarterPhase, QubitId}; +use pecos_neo::command::CommandBuilder; +use pecos_neo::noise::{ + ComposableNoiseModel, MeasurementChannel, PreparationChannel, SingleQubitChannel, + TwoQubitChannel, +}; +use pecos_neo::tool::sim_neo; +use pecos_simulators::measurement_sampler::SampleResult; +use pyo3::prelude::*; + +#[derive(serde::Deserialize)] +struct RecDef { + records: Vec, +} + +fn measurement_record_index(record: i32, num_measurements: usize) -> Option { + let idx = if record < 0 { + i32::try_from(num_measurements).ok()?.checked_add(record)? + } else { + record + }; + usize::try_from(idx) + .ok() + .filter(|&idx| idx < num_measurements) +} + +// ============================================================================ +// Columnar raw measurement result (stays in Rust memory) +// ============================================================================ + +/// Raw measurement batch — common result type for all sim_neo backends. +/// +/// Stores either columnar bit-packed data (meas_sampling) or row-major +/// data (stabilizer/statevec). The Python API is identical regardless of +/// storage: `result[shot]`, `result.get(shot, meas)`, iteration, `len()`. +#[pyclass(name = "RawMeasurementResult", module = "pecos_rslib_exp")] +pub struct PyRawMeasurementResult { + storage: RawMeasurementStorage, +} + +enum RawMeasurementStorage { + /// Columnar bit-packed (from meas_sampling geometric sampler). + Columnar(SampleResult), + /// Row-major (from gate-by-gate stabilizer/statevec simulation). + RowMajor { + rows: Vec>, + num_measurements: usize, + }, +} + +impl RawMeasurementStorage { + fn num_shots(&self) -> usize { + match self { + Self::Columnar(s) => s.shots(), + Self::RowMajor { rows, .. } => rows.len(), + } + } + + fn num_measurements(&self) -> usize { + match self { + Self::Columnar(s) => s.num_measurements(), + Self::RowMajor { + num_measurements, .. + } => *num_measurements, + } + } + + fn get(&self, shot: usize, measurement: usize) -> u8 { + match self { + Self::Columnar(s) => u8::from(s.get(shot, measurement).0), + Self::RowMajor { rows, .. } => rows[shot][measurement], + } + } + + fn get_shot(&self, shot: usize) -> Vec { + match self { + Self::Columnar(s) => { + let n = s.num_measurements(); + let mut row = Vec::with_capacity(n); + for meas in 0..n { + row.push(u8::from(s.get(shot, meas).0)); + } + row + } + Self::RowMajor { rows, .. } => rows[shot].clone(), + } + } +} + +impl PyRawMeasurementResult { + /// Convert a signed Python index to a checked usize. + /// Negative indices raise IndexError (no Python-list-style wrapping). + fn check_index(idx: isize, len: usize, name: &str) -> PyResult { + if idx < 0 { + return Err(pyo3::exceptions::PyIndexError::new_err(format!( + "negative {name} index {idx}" + ))); + } + let u = usize::try_from(idx).map_err(|_| { + pyo3::exceptions::PyIndexError::new_err(format!("invalid {name} index {idx}")) + })?; + if u >= len { + return Err(pyo3::exceptions::PyIndexError::new_err(format!( + "{name} {u} out of range ({len})" + ))); + } + Ok(u) + } + + /// Construct from columnar SampleResult (meas_sampling path). + pub fn from_columnar(result: SampleResult) -> Self { + Self { + storage: RawMeasurementStorage::Columnar(result), + } + } + + /// Construct from row-major data (stabilizer/statevec path). + pub fn from_rows(rows: Vec>) -> Self { + let num_measurements = rows.first().map_or(0, Vec::len); + Self { + storage: RawMeasurementStorage::RowMajor { + rows, + num_measurements, + }, + } + } +} + +#[pymethods] +impl PyRawMeasurementResult { + /// Number of shots. + #[getter] + fn num_shots(&self) -> usize { + self.storage.num_shots() + } + + /// Number of measurements per shot. + #[getter] + fn num_measurements(&self) -> usize { + self.storage.num_measurements() + } + + /// 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")?; + let m = Self::check_index(measurement, self.storage.num_measurements(), "measurement")?; + Ok(self.storage.get(s, m)) + } + + /// Get one full shot as a list of u8. + fn get_shot(&self, shot: isize) -> PyResult> { + let s = Self::check_index(shot, self.storage.num_shots(), "shot")?; + Ok(self.storage.get_shot(s)) + } + + /// Materialize all shots as list[list[int]]. + fn to_list(&self) -> Vec> { + let n = self.storage.num_shots(); + (0..n).map(|i| self.storage.get_shot(i)).collect() + } + + fn __len__(&self) -> usize { + self.storage.num_shots() + } + + fn __getitem__(&self, shot: isize) -> PyResult> { + let s = Self::check_index(shot, self.storage.num_shots(), "index")?; + Ok(self.storage.get_shot(s)) + } +} + +// ============================================================================ +// Noise model builder +// ============================================================================ + +/// Builder for composable noise models. +/// +/// Example: +/// depolarizing().p1(0.003).p2(0.003).p_meas(0.003).p_prep(0.003).idle_rz(0.05) +#[pyclass( + name = "NoiseModelBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +#[derive(Clone, Default)] +pub struct PyNoiseModelBuilder { + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + idle_rz_angle: f64, +} + +#[pymethods] +impl PyNoiseModelBuilder { + #[new] + fn new() -> Self { + Self::default() + } + + /// Single-qubit depolarizing rate (X/Y/Z each with p/3 after unitary 1q gates). + fn p1(&self, p: f64) -> Self { + Self { + p1: p, + ..self.clone() + } + } + + /// Two-qubit depolarizing rate (15 Paulis each with p/15 after unitary 2q gates). + fn p2(&self, p: f64) -> Self { + Self { + p2: p, + ..self.clone() + } + } + + /// Measurement bit-flip rate (symmetric, after MZ). + fn p_meas(&self, p: f64) -> Self { + Self { + p_meas: p, + ..self.clone() + } + } + + /// Preparation error rate (X flip after PZ/QAlloc). + fn p_prep(&self, p: f64) -> Self { + Self { + p_prep: p, + ..self.clone() + } + } + + /// Coherent idle RZ angle (radians) applied to both qubits after each CX. + fn idle_rz(&self, angle: f64) -> Self { + Self { + idle_rz_angle: angle, + ..self.clone() + } + } +} + +impl PyNoiseModelBuilder { + fn build_noise(&self) -> Option { + let has_noise = self.p1 > 0.0 + || self.p2 > 0.0 + || self.p_meas > 0.0 + || self.p_prep > 0.0 + || self.idle_rz_angle > 0.0; + + if !has_noise { + return None; + } + + let mut noise = ComposableNoiseModel::new(); + if self.p1 > 0.0 { + noise = noise.add_channel(SingleQubitChannel::depolarizing(self.p1)); + } + if self.p2 > 0.0 { + noise = noise.add_channel(TwoQubitChannel::depolarizing(self.p2)); + } + if self.p_meas > 0.0 { + noise = noise.add_channel(MeasurementChannel::symmetric(self.p_meas)); + } + if self.p_prep > 0.0 { + noise = noise.add_channel(PreparationChannel::new(self.p_prep)); + } + if self.idle_rz_angle > 0.0 { + noise = noise.add_channel(crate::coherent_idle_channel::CoherentIdleChannel::new( + self.idle_rz_angle, + )); + } + Some(noise) + } +} + +/// Create a noise model builder. +#[pyfunction] +pub fn depolarizing() -> PyNoiseModelBuilder { + PyNoiseModelBuilder::new() +} + +/// Marker type for the stabilizer (SparseStab) backend. +/// +/// Pass to `.quantum()` to select the stabilizer simulator. +/// +/// Example: +/// sim_neo(tc).quantum(stabilizer()).noise(depolarizing().p2(0.01)).shots(10000).run() +#[pyclass( + name = "StabilizerBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +#[derive(Clone)] +pub struct PyStabilizerBuilder; + +#[pymethods] +impl PyStabilizerBuilder { + #[new] + fn new() -> Self { + Self + } +} + +/// Marker type for the state vector backend. +/// +/// Pass to `.quantum()` to select the state vector simulator. +/// Supports arbitrary gates including non-Clifford (T, RZ, etc.). +/// +/// Example: +/// sim_neo(tc).quantum(statevec()).noise(depolarizing().idle_rz(0.05)).shots(10000).run() +#[pyclass( + name = "StateVecBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +#[derive(Clone)] +pub struct PyStateVecBuilder; + +#[pymethods] +impl PyStateVecBuilder { + #[new] + fn new() -> Self { + Self + } +} + +/// Create a state vector backend builder. +/// +/// Example: +/// sim_neo(tc).quantum(statevec()).noise(...).shots(10000).run() +#[pyfunction] +pub fn statevec() -> PyStateVecBuilder { + PyStateVecBuilder +} + +/// Create a stabilizer (SparseStab) backend builder. +/// +/// Example: +/// sim_neo(tc).quantum(stabilizer()).noise(...).shots(10000).run() +#[pyfunction] +pub fn stabilizer() -> PyStabilizerBuilder { + PyStabilizerBuilder +} + +// ============================================================================ +// StabMps backend builder +// ============================================================================ + +/// Builder for StabMps backend configuration. +/// +/// Example: +/// stab_mps().lazy_measure().max_bond_dim(128) +#[pyclass( + name = "StabMpsBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +#[derive(Clone)] +pub struct PyStabMpsBuilder { + pub(crate) inner: crate::stabmps_builder::StabMpsBuilder, +} + +#[pymethods] +impl PyStabMpsBuilder { + #[new] + fn new() -> Self { + Self { + inner: crate::stabmps_builder::StabMpsBuilder::new(), + } + } + + fn lazy_measure(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { + slf.inner.lazy_measure = true; + slf + } + + fn max_bond_dim(mut slf: PyRefMut<'_, Self>, bd: usize) -> PyRefMut<'_, Self> { + slf.inner.max_bond_dim = bd; + slf + } + + fn max_truncation_error(mut slf: PyRefMut<'_, Self>, err: f64) -> PyRefMut<'_, Self> { + slf.inner.max_truncation_error = Some(err); + slf + } + + fn merge_rz(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { + slf.inner.merge_rz = true; + slf + } +} + +/// Create a StabMps backend builder. +#[pyfunction] +pub fn stab_mps() -> PyStabMpsBuilder { + PyStabMpsBuilder::new() +} + +// ============================================================================ +// sim_neo builder +// ============================================================================ + +/// Measurement sampling backend builder. +#[pyclass( + name = "MeasSamplingBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +#[derive(Clone)] +pub struct PyMeasSamplingBuilder { + method: String, +} + +#[pymethods] +impl PyMeasSamplingBuilder { + #[new] + #[pyo3(signature = (method="auto"))] + fn new(method: &str) -> Self { + Self { + method: method.to_string(), + } + } +} + +/// Create a measurement sampling backend builder. +/// +/// Samples raw measurement rows from a whole-circuit measurement model. Fast, handles coherent noise at any distance. +/// +/// Methods: +/// - "auto": uses coherent_dem if idle_rz > 0, else stochastic (default) +/// - "stochastic": DEM from backward Pauli propagation +/// - "coherent": DEM from EEG backward Heisenberg walk +#[pyfunction] +#[pyo3(signature = (method="auto"))] +pub fn meas_sampling(method: &str) -> PyMeasSamplingBuilder { + PyMeasSamplingBuilder::new(method) +} + +/// Builder for sim_neo simulations. Mirrors the Rust-side `SimNeoBuilder`. +#[pyclass( + name = "SimNeoBuilder", + skip_from_py_object, + module = "pecos_rslib_exp" +)] +#[derive(Clone)] +pub struct PySimNeoBuilder { + commands: pecos_neo::command::CommandQueue, + /// 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, + noise_config: Option, + backend: String, + stabmps_config: Option, + meas_sampling_method: Option, +} + +#[pymethods] +impl PySimNeoBuilder { + /// Set the quantum backend. + /// + /// Accepts: + /// - `state_vec()` — state vector (exact, supports non-Clifford gates) + /// - `stabilizer()` — SparseStab (fast Clifford-only) + /// - `stab_mps()` — hybrid stabilizer-MPS (Clifford + T gates) + /// + /// Example: + /// sim_neo(tc).quantum(state_vec()).noise(...).run() + /// sim_neo(tc).quantum(stabilizer()).noise(...).run() + /// sim_neo(tc).quantum(stab_mps().lazy_measure()).noise(...).run() + fn quantum(&self, builder: &Bound<'_, PyAny>) -> PyResult { + let mut c = self.clone(); + if builder.is_instance_of::() { + let b: PyRef<'_, PyMeasSamplingBuilder> = builder.extract()?; + c.backend = "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.stabmps_config = Some(b.inner.clone()); + c.meas_sampling_method = None; + } else if builder.is_instance_of::() { + c.backend = "stabilizer".to_string(); + c.stabmps_config = None; + c.meas_sampling_method = None; + } else if builder.is_instance_of::() { + c.backend = "statevec".to_string(); + c.stabmps_config = None; + c.meas_sampling_method = None; + } else { + return Err(pyo3::exceptions::PyTypeError::new_err( + "quantum() expects statevec(), stabilizer(), stab_mps(), or meas_sampling()", + )); + } + Ok(c) + } + + /// Set the noise model. + fn noise(&self, noise_builder: &PyNoiseModelBuilder) -> Self { + let mut c = self.clone(); + c.noise_config = Some(noise_builder.clone()); + c + } + + /// Set number of shots. + fn shots(&self, n: usize) -> Self { + let mut c = self.clone(); + c.shots = n; + c + } + + /// Set random seed. + fn seed(&self, s: u64) -> Self { + let mut c = self.clone(); + c.seed = s; + c + } + + /// Run the simulation and return per-shot measurement outcomes. + /// + /// All backends return `RawMeasurementResult` which supports: + /// `result[shot]`, `result.get(shot, meas)`, `len(result)`, iteration. + fn run(&self) -> PyResult { + if self.tick_circuit.has_channel_operations() { + return self.run_inline_channel_circuit(); + } + + if self.backend == "meas_sampling" { + return self.run_meas_sampling(); + } + + let noise = self + .noise_config + .as_ref() + .and_then(PyNoiseModelBuilder::build_noise); + + let mut builder = sim_neo(self.commands.clone()) + .shots(self.shots) + .seed(self.seed); + + if let Some(n) = noise { + builder = builder.noise(n); + } + + match self.backend.as_str() { + "stabmps" => { + let config = self.stabmps_config.clone().unwrap_or_default(); + builder = builder.quantum(pecos_neo::tool::custom_backend_from_factory(config)); + } + "statevec" => { + builder = builder.quantum(pecos_neo::tool::state_vector()); + } + "stabilizer" => { + builder = builder.quantum(pecos_neo::tool::sparse_stab()); + } + _ => { + return Err(PyErr::new::(format!( + "Unknown backend: {}", + self.backend + ))); + } + } + + let mut sim = builder.build(); + let results = sim.run(); + + let mut all_shots = Vec::with_capacity(self.shots); + for shot_outcomes in &results.outcomes { + let meas: Vec = shot_outcomes.iter().map(|o| u8::from(o.outcome)).collect(); + all_shots.push(meas); + } + + Ok(PyRawMeasurementResult::from_rows(all_shots)) + } +} + +impl PySimNeoBuilder { + 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()", + )); + } + + 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}" + ))), + } + } + + fn run_inline_channel_density_matrix(&self) -> PyResult { + let rows = pecos_neo::inline_channel::run_inline_channels_density_matrix( + &self.tick_circuit, + self.shots, + self.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 { + let rows = pecos_neo::inline_channel::run_inline_pauli_channels_stabilizer( + &self.tick_circuit, + self.shots, + self.seed, + ) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(PyRawMeasurementResult::from_rows(rows)) + } + + /// DEM sampling backend: dispatches to stochastic or coherent path based on method. + fn run_meas_sampling(&self) -> PyResult { + let noise_config = self.noise_config.as_ref().ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err("DEM sampling requires .noise() to be set") + })?; + + let method = self.meas_sampling_method.as_deref().unwrap_or("auto"); + + let has_coherent = noise_config.idle_rz_angle.abs() > 1e-15; + + match method { + "stochastic" => { + if has_coherent { + return Err(pyo3::exceptions::PyValueError::new_err( + "DEM sampling method='stochastic' cannot handle idle_rz noise. \ + Use method='coherent' or method='auto'.", + )); + } + self.run_stochastic_meas_columnar() + } + "coherent" | "coherent_approx" | "coherent_exact" => { + let rows = self.run_coherent_meas_sampling(noise_config, method)?; + Ok(PyRawMeasurementResult::from_rows(rows)) + } + "auto" => { + if has_coherent { + let rows = self.run_coherent_meas_sampling(noise_config, "coherent_approx")?; + Ok(PyRawMeasurementResult::from_rows(rows)) + } else { + self.run_stochastic_meas_columnar() + } + } + other => Err(pyo3::exceptions::PyValueError::new_err(format!( + "Unknown DEM sampling method: {other:?}. \ + Use 'auto', 'stochastic', 'coherent', 'coherent_approx', or 'coherent_exact'." + ))), + } + } + + /// Stochastic path: columnar raw-measurement sampling. + fn run_stochastic_meas_columnar(&self) -> PyResult { + use pecos_qec::fault_tolerance::fault_sampler::{ + self, RawMeasurementPlan, StochasticNoiseParams, + }; + + let noise_config = self.noise_config.as_ref().ok_or_else(|| { + pyo3::exceptions::PyRuntimeError::new_err("DEM sampling requires .noise() to be set") + })?; + + let history = run_symbolic_sim_with_pz(&self.tick_circuit)?; + + let noise = StochasticNoiseParams { + p1: noise_config.p1, + p2: noise_config.p2, + p_meas: noise_config.p_meas, + p_prep: noise_config.p_prep, + }; + let mechanisms = fault_sampler::build_fault_table(&self.tick_circuit, &noise) + .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); + + Ok(PyRawMeasurementResult::from_columnar(result)) + } + + /// Coherent path: EEG DemGenerator with measurement synthesis. + fn run_coherent_meas_sampling( + &self, + noise_config: &PyNoiseModelBuilder, + method: &str, + ) -> PyResult>> { + use pecos_eeg::dem_generator::select_generator; + use pecos_eeg::dem_simulator::{CircuitMeasurementMeta, run_dem_simulation}; + + // Extract metadata from stored TickCircuit + let num_meas_attr = self + .tick_circuit + .get_meta("num_measurements") + .and_then(|a| { + if let pecos_quantum::Attribute::String(s) = a { + s.parse::().ok() + } else { + None + } + }) + .ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "TickCircuit missing num_measurements metadata", + ) + })?; + let det_json = self + .tick_circuit + .get_meta("detectors") + .and_then(|a| { + if let pecos_quantum::Attribute::String(s) = a { + Some(s.clone()) + } else { + None + } + }) + .unwrap_or_else(|| "[]".to_string()); + let obs_json = self + .tick_circuit + .get_meta("observables") + .and_then(|a| { + if let pecos_quantum::Attribute::String(s) = a { + Some(s.clone()) + } else { + None + } + }) + .unwrap_or_else(|| "[]".to_string()); + + let det_records: Vec> = serde_json::from_str::>(&det_json) + .map(|defs| defs.iter().map(|d| d.records.clone()).collect()) + .unwrap_or_default(); + let obs_records: Vec> = serde_json::from_str::>(&obs_json) + .map(|defs| defs.iter().map(|d| d.records.clone()).collect()) + .unwrap_or_default(); + + let meta = CircuitMeasurementMeta { + num_measurements: num_meas_attr, + detector_records: det_records, + observable_records: obs_records, + }; + + let noise = pecos_eeg::noise::UniformNoise { + idle_rz: noise_config.idle_rz_angle, + p1: noise_config.p1, + p2: noise_config.p2, + p_meas: noise_config.p_meas, + p_prep: noise_config.p_prep, + }; + + let gates = commands_to_gates(&self.commands); + let generator = select_generator(method, noise_config.idle_rz_angle); + + let result = run_dem_simulation( + &gates, + &noise, + &meta, + generator.as_ref(), + self.shots, + self.seed, + ); + Ok(result.measurements) + } +} + +/// Convert CommandQueue to Vec for EEG analysis. +fn commands_to_gates(commands: &pecos_neo::command::CommandQueue) -> Vec { + use pecos_core::{GateAngles, GateMeasIds, GateParams}; + + commands + .iter() + .map(|cmd| { + let qubits = cmd.qubits.iter().copied().collect(); + let mut angles = GateAngles::new(); + for &a in &cmd.angles { + angles.push(a); + } + // Convert pecos_neo::GateType to pecos_core::GateType + let gate_type: pecos_core::gate_type::GateType = cmd.gate_type.into(); + Gate { + gate_type, + qubits, + angles, + params: GateParams::new(), + meas_ids: GateMeasIds::new(), + channel: None, + } + }) + .collect() +} + +// ============================================================================ +// Entry point +// ============================================================================ + +/// Create a sim_neo simulation builder from a TickCircuit. +/// +/// Example: +/// 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).idle_rz(0.05)) +/// .shots(5000) +/// .seed(42) +/// .run()) +#[pyfunction] +#[pyo3(name = "sim_neo")] +pub fn py_sim_neo(tick_circuit: &Bound<'_, PyAny>) -> PyResult { + // Build a Rust TickCircuit from the Python object. + // This is the canonical circuit representation used by DemSampler. + let tc = build_rust_tick_circuit(tick_circuit)?; + let commands = if tc.has_channel_operations() { + pecos_neo::command::CommandQueue::new() + } else { + extract_commands(tick_circuit)? + }; + + Ok(PySimNeoBuilder { + commands, + tick_circuit: std::sync::Arc::new(tc), + shots: 1, + seed: 42, + noise_config: None, + backend: "statevec".to_string(), + stabmps_config: None, + meas_sampling_method: None, + }) +} + +/// Build a proper Rust TickCircuit from a Python TickCircuit object. +/// +/// First tries to extract the inner Rust TickCircuit directly (fast path). +/// Falls back to rebuilding from Python gate iteration (slow path). +fn build_rust_tick_circuit(py_tc: &Bound<'_, PyAny>) -> PyResult { + // Fast path: try to access the inner TickCircuit directly. + // The Python TickCircuit wraps `pub inner: TickCircuit` — access via + // the `_inner_tick_circuit()` method if available, or via serialization. + if let Ok(tc_bytes) = py_tc.call_method0("_serialize_inner") + && let Ok(bytes) = tc_bytes.extract::>() + { + // Deserialize — but TickCircuit doesn't impl serde. Skip. + let _ = bytes; + } + + // The only reliable fast path: call `to_dag_circuit()` on the Python TC, + // then use DemSampler::from_circuit on that DagCircuit. But we can't get + // the DagCircuit across crate boundaries easily. + // + // For now: reconstruct via gate iteration (matches original structure if + // we respect tick boundaries from the Python object). + build_rust_tick_circuit_from_gates(py_tc) +} + +/// Reconstruct TickCircuit from Python gate iteration, preserving tick structure. +/// +/// Respects the original tick boundaries: all gates from the same Python tick +/// go into the same Rust tick. Uses typed .mz() for measurements and .pz() for +/// prep within each tick (these consume the TickHandle, so we process them after +/// other gates in the tick). +fn build_rust_tick_circuit_from_gates( + py_tc: &Bound<'_, PyAny>, +) -> PyResult { + use pecos_quantum::{Attribute, TickMeasRef}; + + let num_ticks: usize = py_tc.call_method0("num_ticks")?.extract()?; + let mut tc = pecos_quantum::TickCircuit::default(); + let mut all_meas_refs: Vec = Vec::new(); + + for tick_idx in 0..num_ticks { + let py_tick = py_tc.call_method1("get_tick", (tick_idx,))?; + let py_gates = py_tick.call_method0("gate_batches")?; + let gates: Vec> = py_gates.extract()?; + + // Separate gates by type: MZ, PZ, and other + let mut mz_qubits: Vec = Vec::new(); + let mut pz_qubits: Vec = Vec::new(); + let mut other_gates: Vec = Vec::new(); + + for gate in &gates { + let gate_type_obj = gate.getattr("gate_type")?; + let gate_name: String = format!("{gate_type_obj:?}"); + let gate_name = gate_name + .split('.') + .next_back() + .unwrap_or(&gate_name) + .to_string(); + let py_qubits = gate.getattr("qubits")?; + let qubits: Vec = py_qubits.extract()?; + let qubit_ids: Vec = + qubits.iter().map(|&q| pecos_core::QubitId(q)).collect(); + + match gate_name.as_str() { + "MZ" | "Measure" | "MeasureFree" => { + mz_qubits.extend(qubit_ids); + } + "QAlloc" | "PZ" | "Prep" => { + pz_qubits.extend(qubit_ids); + } + _ => { + let core_gate = build_gate_from_python(gate, &gate_name, &qubit_ids)?; + other_gates.push(core_gate); + } + } + } + + // Add PZ first (prep before other gates) + if !pz_qubits.is_empty() { + tc.tick().pz(&pz_qubits); + } + + // Add other gates in one tick (error on qubit conflicts) + if !other_gates.is_empty() { + let mut tick_handle = tc.tick(); + for g in &other_gates { + if let Err(e) = tick_handle.try_add_gate(g.clone()) { + return Err(pyo3::exceptions::PyRuntimeError::new_err(format!( + "Gate conflict in tick {tick_idx}: {e}" + ))); + } + } + } + + // Add MZ last (measure after other gates) + if !mz_qubits.is_empty() { + let refs = tc.tick().mz(&mz_qubits); + all_meas_refs.extend(refs); + } + } + + // Copy metadata from Python TickCircuit + if let Ok(num_meas) = py_tc.call_method1("get_meta", ("num_measurements",)) + && let Ok(s) = num_meas.extract::() + { + tc.set_meta("num_measurements", Attribute::String(s)); + } + if let Ok(det_json) = py_tc.call_method1("get_meta", ("detectors",)) + && let Ok(s) = det_json.extract::() + { + // Create structured annotations from JSON + create_annotations_from_json(&mut tc, &s, &all_meas_refs, true); + tc.set_meta("detectors", Attribute::String(s)); + } + if let Ok(obs_json) = py_tc.call_method1("get_meta", ("observables",)) + && let Ok(s) = obs_json.extract::() + { + create_annotations_from_json(&mut tc, &s, &all_meas_refs, false); + tc.set_meta("observables", Attribute::String(s)); + } + copy_tracked_pauli_annotations_from_python(py_tc, &mut tc)?; + + // Compact for performance + tc.compact_ticks(); + + Ok(tc) +} + +fn copy_tracked_pauli_annotations_from_python( + py_tc: &pyo3::Bound<'_, pyo3::PyAny>, + tc: &mut pecos_quantum::TickCircuit, +) -> PyResult<()> { + let Ok(annotations) = py_tc.call_method0("annotations") else { + return Ok(()); + }; + + for ann in annotations.try_iter()? { + let ann = ann?; + let kind: String = ann.get_item("kind")?.extract()?; + if kind != "tracked_pauli" { + continue; + } + let pauli_obj = ann.get_item("pauli")?; + let pauli_text = pauli_obj.str()?.to_string(); + let pauli = parse_python_pauli_string(&pauli_text).ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err(format!( + "Could not parse tracked Pauli annotation: {pauli_text}" + )) + })?; + let label: Option = ann.get_item("label")?.extract()?; + if let Some(label) = label { + tc.tracked_pauli_labeled(&label, pauli); + } else { + tc.tracked_pauli(pauli); + } + } + + Ok(()) +} + +fn parse_python_pauli_string(text: &str) -> Option { + let text = text.trim(); + let text = text + .strip_prefix("+i*") + .or_else(|| text.strip_prefix("-i*")) + .or_else(|| text.strip_prefix('-')) + .unwrap_or(text) + .trim(); + if text.is_empty() || text == "I" { + return Some(PauliString::new()); + } + + let mut paulis = Vec::new(); + for token in text.split_whitespace() { + let mut chars = token.chars(); + let p = match chars.next()? { + 'X' | 'x' => pecos_core::Pauli::X, + 'Y' | 'y' => pecos_core::Pauli::Y, + 'Z' | 'z' => pecos_core::Pauli::Z, + 'I' | 'i' => continue, + _ => return None, + }; + let rest = chars.as_str().strip_prefix('_').unwrap_or(chars.as_str()); + let qubit = rest.parse::().ok()?; + paulis.push((p, pecos_core::QubitId(qubit))); + } + + Some(PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + paulis, + )) +} + +fn channel_expr_from_python_gate(gate: &Bound<'_, PyAny>) -> PyResult { + let terms: Vec<(f64, Vec<(String, usize)>)> = + gate.call_method0("channel_mixed_pauli_terms")?.extract()?; + let mut ops = Vec::with_capacity(terms.len()); + for (probability, terms) in terms { + let mut paulis = Vec::with_capacity(terms.len()); + for (label, qubit) in terms { + let pauli = match label.as_str() { + "I" => continue, + "X" => Pauli::X, + "Y" => Pauli::Y, + "Z" => Pauli::Z, + other => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "unsupported channel Pauli label {other:?}" + ))); + } + }; + paulis.push((pauli, QubitId(qubit))); + } + let pauli_string = PauliString::with_phase_and_paulis(QuarterPhase::PlusOne, paulis); + ops.push((probability, pecos_core::UnitaryRep::from(pauli_string))); + } + Ok(ChannelExpr::MixedUnitary(ops)) +} + +/// Create detector or observable annotations from JSON metadata. +fn create_annotations_from_json( + tc: &mut pecos_quantum::TickCircuit, + json_str: &str, + all_meas_refs: &[pecos_quantum::TickMeasRef], + is_detector: bool, +) { + let num_meas = all_meas_refs.len(); + if let Ok(defs) = serde_json::from_str::>(json_str) { + for def in &defs { + let refs: Vec = def + .records + .iter() + .filter_map(|&rec| { + let abs_idx = measurement_record_index(rec, num_meas)?; + all_meas_refs.get(abs_idx).copied() + }) + .collect(); + if !refs.is_empty() { + if is_detector { + tc.detector(&refs); + } else { + tc.observable(&refs); + } + } + } + } +} + +/// Build a pecos_core::Gate from a Python gate object. +fn build_gate_from_python( + gate: &Bound<'_, PyAny>, + gate_name: &str, + qubit_ids: &[pecos_core::QubitId], +) -> PyResult { + use pecos_core::gate_type::GateType; + use pecos_core::{Gate, GateAngles, GateMeasIds, GateParams}; + + if gate_name == "Channel" { + return Ok(Gate::channel(channel_expr_from_python_gate(gate)?)); + } + + let gate_type = match gate_name { + "H" => GateType::H, + "X" => GateType::X, + "Y" => GateType::Y, + "Z" => GateType::Z, + "F" => GateType::F, + "Fdg" => GateType::Fdg, + "CX" | "CNOT" => GateType::CX, + "CY" => GateType::CY, + "CZ" => GateType::CZ, + "SZ" | "S" => GateType::SZ, + "SZdg" | "Sdg" => GateType::SZdg, + "SX" => GateType::SX, + "SXdg" => GateType::SXdg, + "SY" => GateType::SY, + "SYdg" => GateType::SYdg, + "T" => GateType::T, + "Tdg" => GateType::Tdg, + "SWAP" => GateType::SWAP, + "RZ" => GateType::RZ, + "RX" => GateType::RX, + "RY" => GateType::RY, + "RZZ" => GateType::RZZ, + "RXX" => GateType::RXX, + "RYY" => GateType::RYY, + "SZZ" => GateType::SZZ, + "SZZdg" => GateType::SZZdg, + "SXX" => GateType::SXX, + "SXXdg" => GateType::SXXdg, + "SYY" => GateType::SYY, + "SYYdg" => GateType::SYYdg, + "R1XY" => GateType::R1XY, + "I" | "Idle" => GateType::I, + other => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Unsupported gate type for meas_sampling simulation: {other}" + ))); + } + }; + + let mut angles = GateAngles::new(); + if let Ok(py_angle) = gate.getattr("angle") + && let Ok(a) = py_angle.extract::() + { + angles.push(pecos_core::Angle64::from_radians(a)); + } + if let Ok(py_angles) = gate.getattr("angles") + && let Ok(a_list) = py_angles.extract::>() + { + for a in a_list { + angles.push(pecos_core::Angle64::from_radians(a)); + } + } + + Ok(Gate { + gate_type, + qubits: qubit_ids.iter().copied().collect(), + angles, + params: GateParams::new(), + meas_ids: GateMeasIds::new(), + channel: None, + }) +} + +// ============================================================================ +// Circuit extraction +// ============================================================================ + +/// Extract a CommandQueue from a Python TickCircuit by iterating its stored gate batches. +fn extract_commands(py_tc: &Bound<'_, PyAny>) -> PyResult { + let num_ticks: usize = py_tc.call_method0("num_ticks")?.extract()?; + let mut cb = CommandBuilder::new(); + + for tick_idx in 0..num_ticks { + let py_tick = py_tc.call_method1("get_tick", (tick_idx,))?; + let py_gates = py_tick.call_method0("gate_batches")?; + let gates: Vec> = py_gates.extract()?; + + for gate in &gates { + let gate_type_obj = gate.getattr("gate_type")?; + let name: String = gate_type_obj.getattr("name")?.extract()?; + let qubits: Vec = gate.getattr("qubits")?.extract()?; + + match name.as_str() { + "QAlloc" | "PZ" => { + cb = cb.pz(&qubits); + } + "H" => { + cb = cb.h(&qubits); + } + "F" => { + cb = cb.f(&qubits); + } + "Fdg" => { + cb = cb.fdg(&qubits); + } + "X" => { + cb = cb.x(&qubits); + } + "Y" => { + cb = cb.y(&qubits); + } + "Z" => { + cb = cb.z(&qubits); + } + "SZ" => { + cb = cb.sz(&qubits); + } + "SZdg" => { + cb = cb.szdg(&qubits); + } + "SX" => { + cb = cb.sx(&qubits); + } + "SXdg" => { + cb = cb.sxdg(&qubits); + } + "SY" => { + cb = cb.sy(&qubits); + } + "SYdg" => { + cb = cb.sydg(&qubits); + } + "CX" => { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.cx(&pairs); + } + "CY" => { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.cy(&pairs); + } + "CZ" => { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.cz(&pairs); + } + "SZZ" => { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.szz(&pairs); + } + "SZZdg" => { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.szzdg(&pairs); + } + "SXX" => { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.sxx(&pairs); + } + "SXXdg" => { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.sxxdg(&pairs); + } + "SYY" => { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.syy(&pairs); + } + "SYYdg" => { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.syydg(&pairs); + } + "SWAP" => { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.swap(&pairs); + } + "T" => { + cb = cb.t(&qubits); + } + "Tdg" => { + cb = cb.tdg(&qubits); + } + "MZ" => { + cb = cb.mz(&qubits); + } + "RX" => { + let angles: Vec = gate.getattr("angles")?.extract().unwrap_or_default(); + if let Some(&angle) = angles.first() { + cb = cb.rx(&qubits, Angle64::from_radians(angle)); + } + } + "RY" => { + let angles: Vec = gate.getattr("angles")?.extract().unwrap_or_default(); + if let Some(&angle) = angles.first() { + cb = cb.ry(&qubits, Angle64::from_radians(angle)); + } + } + "RZ" => { + let angles: Vec = gate.getattr("angles")?.extract().unwrap_or_default(); + if let Some(&angle) = angles.first() { + cb = cb.rz(&qubits, Angle64::from_radians(angle)); + } + } + "R1XY" => { + let angles: Vec = gate.getattr("angles")?.extract().unwrap_or_default(); + if angles.len() >= 2 { + cb = cb.r1xy( + &qubits, + Angle64::from_radians(angles[0]), + Angle64::from_radians(angles[1]), + ); + } + } + "RZZ" => { + let angles: Vec = gate.getattr("angles")?.extract().unwrap_or_default(); + if let Some(&angle) = angles.first() { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.rzz(&pairs, Angle64::from_radians(angle)); + } + } + "RXX" => { + let angles: Vec = gate.getattr("angles")?.extract().unwrap_or_default(); + if let Some(&angle) = angles.first() { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.rxx(&pairs, Angle64::from_radians(angle)); + } + } + "RYY" => { + let angles: Vec = gate.getattr("angles")?.extract().unwrap_or_default(); + if let Some(&angle) = angles.first() { + let pairs: Vec<(usize, usize)> = + qubits.chunks(2).map(|c| (c[0], c[1])).collect(); + cb = cb.ryy(&pairs, Angle64::from_radians(angle)); + } + } + "I" | "Idle" => { + // Identity/Idle gates: skip (no-op for simulation) + } + _ => { + return Err(PyErr::new::(format!( + "Unsupported gate type '{name}' in extract_commands. \ + Add support in sim_neo_bindings.rs or lower to supported gates \ + with tc.lower_clifford_rotations()." + ))); + } + } + } + } + + Ok(cb.build()) +} + +/// Run SymbolicSparseStab through a TickCircuit with proper PZ (reset) semantics. +/// +/// Iterates tick-by-tick to match the TickCircuit's measurement numbering, +/// which is what detector and observable definitions reference. +fn run_symbolic_sim_with_pz( + tc: &pecos_quantum::TickCircuit, +) -> PyResult { + pecos_qec::fault_tolerance::fault_sampler::symbolic_measurement_history(tc) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string())) +} + +// ============================================================================ +// Fault Catalog Python API +// ============================================================================ + +/// One fault alternative at a physical location. +#[pyclass(name = "FaultAlternative", module = "pecos_rslib_exp")] +pub struct PyFaultAlternative { + /// "pauli", "measurement_flip", or "prep_flip" + #[pyo3(get)] + kind: String, + /// PauliString object (from pecos.quantum) for Pauli faults, None otherwise + pauli_obj: Py, + /// Measurement indices flipped + #[pyo3(get)] + measurements: Vec, + /// Detector indices flipped + #[pyo3(get)] + detectors: Vec, + /// Observable indices flipped + #[pyo3(get)] + observables: Vec, + /// Tracked-Pauli indices flipped + #[pyo3(get)] + tracked_paulis: Vec, + /// Probability of this alternative given the mechanism fires (1/k) + #[pyo3(get)] + conditional_probability: f64, + /// Marginal per-location alternative probability: p_i / k_i. + /// This is NOT "probability of this fault and no others." Full configuration + /// probabilities require multiplying by no_fault_probability for all other locations. + #[pyo3(get)] + absolute_probability: f64, + /// Total channel probability (same as parent location) + #[pyo3(get)] + channel_probability: f64, +} + +#[pymethods] +impl PyFaultAlternative { + #[getter] + fn pauli(&self, py: Python<'_>) -> Py { + self.pauli_obj.clone_ref(py) + } +} + +/// A physical fault location in the circuit. +#[pyclass(name = "FaultLocation", module = "pecos_rslib_exp")] +pub struct PyFaultLocation { + #[pyo3(get)] + tick: usize, + #[pyo3(get)] + gate_index: usize, + #[pyo3(get)] + gate_type: String, + #[pyo3(get)] + qubits: Vec, + /// "p1", "p2", "p_meas", or "p_prep" + #[pyo3(get)] + channel: String, + #[pyo3(get)] + channel_probability: f64, + /// 1 - channel_probability + #[pyo3(get)] + no_fault_probability: f64, + #[pyo3(get)] + num_alternatives: usize, + #[pyo3(get)] + faults: Vec>, +} + +/// A k-fault configuration yielded by `catalog.fault_configurations(k)`. +#[pyclass(name = "FaultConfiguration", module = "pecos_rslib_exp")] +pub struct PyFaultConfiguration { + #[pyo3(get)] + location_indices: Vec, + #[pyo3(get)] + alternative_indices: Vec, + /// The FaultLocation objects for selected locations. + #[pyo3(get)] + locations: Vec>, + /// The FaultAlternative objects for selected alternatives. + #[pyo3(get)] + faults: Vec>, + #[pyo3(get)] + measurements: Vec, + #[pyo3(get)] + detectors: Vec, + #[pyo3(get)] + observables: Vec, + #[pyo3(get)] + tracked_paulis: Vec, + #[pyo3(get)] + selected_probability: f64, + #[pyo3(get)] + configuration_probability: f64, +} + +/// Lazy Python iterator over k-fault configurations. +#[pyclass(name = "FaultConfigurationIter", module = "pecos_rslib_exp")] +pub struct PyFaultConfigurationIter { + /// Owned Rust iterator (self-contained, no borrows). + inner: pecos_qec::fault_tolerance::fault_sampler::OwnedFaultConfigIter, + /// Python-side location objects for building yielded configs. + py_locations: Vec>, +} + +#[pymethods] +impl PyFaultConfigurationIter { + fn __iter__(slf: PyRef<'_, Self>) -> PyRef<'_, Self> { + slf + } + + fn __next__(&mut self, py: Python<'_>) -> Option { + let config = self.inner.next()?; + + // Build .locations and .faults references + let locations: Vec> = config + .location_indices + .iter() + .map(|&i| self.py_locations[i].clone_ref(py)) + .collect(); + let faults: Vec> = config + .location_indices + .iter() + .zip(config.alternative_indices.iter()) + .map(|(&loc_i, &alt_i)| { + let loc = self.py_locations[loc_i].borrow(py); + loc.faults[alt_i].clone_ref(py) + }) + .collect(); + + Some(PyFaultConfiguration { + location_indices: config.location_indices, + alternative_indices: config.alternative_indices, + locations, + faults, + measurements: config.affected_measurements, + detectors: config.affected_detectors, + observables: config.affected_observables, + tracked_paulis: config.affected_tracked_paulis, + selected_probability: config.selected_probability, + configuration_probability: config.configuration_probability, + }) + } +} + +/// Complete fault catalog for a circuit and noise model. +#[pyclass(name = "FaultCatalog", module = "pecos_rslib_exp")] +pub struct PyFaultCatalog { + /// Physical fault locations in the structural catalog. + #[pyo3(get)] + locations: Vec>, + /// Rust-side catalog for iterator support. + rust_catalog: pecos_qec::fault_tolerance::fault_sampler::FaultCatalog, +} + +fn stochastic_params_from_inputs( + noise: Option<&PyNoiseModelBuilder>, + p1: Option, + p2: Option, + p_meas: Option, + p_prep: Option, +) -> pecos_qec::fault_tolerance::fault_sampler::StochasticNoiseParams { + let mut params = noise.map_or( + pecos_qec::fault_tolerance::fault_sampler::StochasticNoiseParams { + p1: 0.0, + p2: 0.0, + p_meas: 0.0, + p_prep: 0.0, + }, + |noise| pecos_qec::fault_tolerance::fault_sampler::StochasticNoiseParams { + p1: noise.p1, + p2: noise.p2, + p_meas: noise.p_meas, + p_prep: noise.p_prep, + }, + ); + if let Some(p) = p1 { + params.p1 = p; + } + if let Some(p) = p2 { + params.p2 = p; + } + if let Some(p) = p_meas { + params.p_meas = p; + } + if let Some(p) = p_prep { + params.p_prep = p; + } + params +} + +fn py_locations_from_catalog( + py: Python<'_>, + catalog: &pecos_qec::fault_tolerance::fault_sampler::FaultCatalog, +) -> PyResult>> { + use pecos_qec::fault_tolerance::fault_sampler::{FaultChannel, FaultKind}; + + let quantum_mod = py.import("pecos.quantum")?; + let ps_class = quantum_mod.getattr("PauliString")?; + let pauli_enum = quantum_mod.getattr("Pauli")?; + let pauli_x = pauli_enum.getattr("X")?; + let pauli_y = pauli_enum.getattr("Y")?; + let pauli_z = pauli_enum.getattr("Z")?; + + let mut locations = Vec::with_capacity(catalog.locations.len()); + for loc in &catalog.locations { + let mut faults = Vec::with_capacity(loc.faults.len()); + for fault in &loc.faults { + let pauli_obj: Py = if let Some(ps) = &fault.pauli { + let mut pair_list = Vec::new(); + for (p, q) in ps.iter_pairs() { + let py_pauli = match p { + pecos_core::Pauli::X => &pauli_x, + pecos_core::Pauli::Y => &pauli_y, + pecos_core::Pauli::Z => &pauli_z, + pecos_core::Pauli::I => continue, + }; + let pair = pyo3::types::PyTuple::new( + py, + [py_pauli.as_any(), &q.index().into_pyobject(py)?.into_any()], + )?; + pair_list.push(pair.unbind()); + } + let py_list = pyo3::types::PyList::new(py, pair_list.iter().map(|p| p.bind(py)))?; + ps_class.call1((py_list,))?.unbind() + } else { + py.None() + }; + + faults.push(Py::new( + py, + PyFaultAlternative { + kind: match fault.kind { + FaultKind::Pauli => "pauli".to_string(), + FaultKind::MeasurementFlip => "measurement_flip".to_string(), + FaultKind::PrepFlip => "prep_flip".to_string(), + }, + pauli_obj, + measurements: fault.affected_measurements.clone(), + detectors: fault.affected_detectors.clone(), + observables: fault.affected_observables.clone(), + tracked_paulis: fault.affected_tracked_paulis.clone(), + conditional_probability: fault.conditional_probability, + absolute_probability: fault.absolute_probability, + channel_probability: loc.channel_probability, + }, + )?); + } + + locations.push(Py::new( + py, + PyFaultLocation { + tick: loc.tick, + gate_index: loc.gate_index, + gate_type: format!("{:?}", loc.gate_type), + qubits: loc.qubits.clone(), + channel: match loc.channel { + FaultChannel::P1 => "p1", + FaultChannel::P2 => "p2", + FaultChannel::PMeas => "p_meas", + FaultChannel::PPrep => "p_prep", + } + .to_string(), + channel_probability: loc.channel_probability, + no_fault_probability: loc.no_fault_probability, + num_alternatives: loc.num_alternatives, + faults, + }, + )?); + } + Ok(locations) +} + +fn sync_py_catalog_probabilities(py: Python<'_>, catalog: &mut PyFaultCatalog) -> PyResult<()> { + if catalog.locations.len() != catalog.rust_catalog.locations.len() { + catalog.locations = py_locations_from_catalog(py, &catalog.rust_catalog)?; + return Ok(()); + } + + let fault_lengths_match = catalog + .locations + .iter() + .zip(&catalog.rust_catalog.locations) + .all(|(py_loc, rust_loc)| py_loc.borrow(py).faults.len() == rust_loc.faults.len()); + if !fault_lengths_match { + catalog.locations = py_locations_from_catalog(py, &catalog.rust_catalog)?; + return Ok(()); + } + + for (py_loc, rust_loc) in catalog + .locations + .iter() + .zip(&catalog.rust_catalog.locations) + { + let mut loc = py_loc.borrow_mut(py); + loc.channel_probability = rust_loc.channel_probability; + loc.no_fault_probability = rust_loc.no_fault_probability; + loc.num_alternatives = rust_loc.num_alternatives; + for (py_fault, rust_fault) in loc.faults.iter().zip(&rust_loc.faults) { + let mut fault = py_fault.borrow_mut(py); + fault.conditional_probability = rust_fault.conditional_probability; + fault.absolute_probability = rust_fault.absolute_probability; + fault.channel_probability = rust_loc.channel_probability; + } + } + Ok(()) +} + +fn py_fault_catalog_from_rust( + py: Python<'_>, + catalog: pecos_qec::fault_tolerance::fault_sampler::FaultCatalog, +) -> PyResult { + let locations = py_locations_from_catalog(py, &catalog)?; + Ok(PyFaultCatalog { + locations, + rust_catalog: catalog, + }) +} + +#[pymethods] +impl PyFaultCatalog { + fn __len__(&self) -> usize { + self.locations.len() + } + + fn __getitem__(&self, py: Python<'_>, index: isize) -> PyResult> { + let len = isize::try_from(self.locations.len()).map_err(|_| { + pyo3::exceptions::PyIndexError::new_err("fault catalog is too large to index") + })?; + let index = if index < 0 { len + index } else { index }; + if index < 0 || index >= len { + return Err(pyo3::exceptions::PyIndexError::new_err( + "fault catalog index out of range", + )); + } + let index = usize::try_from(index).map_err(|_| { + pyo3::exceptions::PyIndexError::new_err("fault catalog index out of range") + })?; + Ok(self.locations[index].clone_ref(py)) + } + + fn __iter__(slf: PyRef<'_, Self>, py: Python<'_>) -> PyResult> { + let locations = pyo3::types::PyList::new(py, slf.locations.iter().map(|loc| loc.bind(py)))?; + Ok(locations.call_method0("__iter__")?.unbind()) + } + + /// Recompute catalog probabilities for a new stochastic noise point. + #[pyo3(signature = (noise=None, *, p1=None, p2=None, p_meas=None, p_prep=None))] + fn with_noise( + &mut self, + py: Python<'_>, + noise: Option<&PyNoiseModelBuilder>, + p1: Option, + p2: Option, + p_meas: Option, + p_prep: Option, + ) -> PyResult<()> { + let params = stochastic_params_from_inputs(noise, p1, p2, p_meas, p_prep); + self.rust_catalog.with_noise(¶ms); + sync_py_catalog_probabilities(py, self) + } + + /// Return a cloned catalog parameterized at a new stochastic noise point. + #[pyo3(signature = (noise=None, *, p1=None, p2=None, p_meas=None, p_prep=None))] + fn parameterized( + &self, + py: Python<'_>, + noise: Option<&PyNoiseModelBuilder>, + p1: Option, + p2: Option, + p_meas: Option, + p_prep: Option, + ) -> PyResult { + let params = stochastic_params_from_inputs(noise, p1, p2, p_meas, p_prep); + py_fault_catalog_from_rust(py, self.rust_catalog.parameterized(¶ms)) + } + + /// Lazily iterate all k-fault configurations. + /// + /// Returns an iterator yielding `FaultConfiguration` objects one at a time. + fn fault_configurations( + &self, + py: Python<'_>, + k: usize, + ) -> PyResult> { + use pecos_qec::fault_tolerance::fault_sampler::OwnedFaultConfigIter; + let inner = OwnedFaultConfigIter::new(self.rust_catalog.clone(), k); + let py_locations: Vec> = + self.locations.iter().map(|l| l.clone_ref(py)).collect(); + Py::new( + py, + PyFaultConfigurationIter { + inner, + py_locations, + }, + ) + } +} + +/// Build a fault catalog for a circuit, optionally parameterized by a noise model. +/// +/// Returns a ``FaultCatalog`` object with ``catalog.locations``. The catalog +/// also supports direct iteration, indexing, and ``len(catalog)``. +/// +/// Each location has attribute access: ``loc.tick``, ``loc.gate_type``, +/// ``loc.qubits``, ``loc.faults``. +/// +/// Each ``FaultAlternative`` has: ``fault.kind``, ``fault.pauli`` (a real +/// PECOS ``PauliString`` or ``None``), ``fault.detectors``, ``fault.observables``, +/// ``fault.tracked_paulis``, ``fault.measurements``, ``fault.conditional_probability``, +/// ``fault.absolute_probability``, ``fault.channel_probability``. +/// +/// When noise is omitted, returns a structural catalog with zero probabilities. +/// The catalog includes all structurally supported physical fault locations. +#[pyfunction] +#[pyo3(signature = (tick_circuit, noise=None, *, p1=None, p2=None, p_meas=None, p_prep=None))] +pub fn fault_catalog( + tick_circuit: &Bound<'_, PyAny>, + noise: Option<&PyNoiseModelBuilder>, + py: Python<'_>, + p1: Option, + p2: Option, + p_meas: Option, + p_prep: Option, +) -> PyResult { + use pecos_qec::fault_tolerance::fault_sampler::FaultCatalog; + + let tc = build_rust_tick_circuit(tick_circuit)?; + let mut catalog = FaultCatalog::from_circuit(&tc) + .map_err(|e| pyo3::exceptions::PyRuntimeError::new_err(e.to_string()))?; + if noise.is_some() || p1.is_some() || p2.is_some() || p_meas.is_some() || p_prep.is_some() { + let noise_params = stochastic_params_from_inputs(noise, p1, p2, p_meas, p_prep); + catalog.with_noise(&noise_params); + } + py_fault_catalog_from_rust(py, catalog) +} diff --git a/python/pecos-rslib-exp/src/stab_mps_bindings.rs b/python/pecos-rslib-exp/src/stab_mps_bindings.rs index abf19d293..feeb75fea 100644 --- a/python/pecos-rslib-exp/src/stab_mps_bindings.rs +++ b/python/pecos-rslib-exp/src/stab_mps_bindings.rs @@ -324,11 +324,35 @@ impl PyStabMps { self.inner.h(q); Ok(None) } + "F" | "F1" => { + self.inner.f(q); + Ok(None) + } + "Fdg" | "F1d" | "F1dg" => { + self.inner.fdg(q); + Ok(None) + } + "SX" | "SqrtX" | "Q" => { + self.inner.sx(q); + Ok(None) + } + "SXdg" | "SqrtXdg" | "SqrtXd" | "Qd" => { + self.inner.sxdg(q); + Ok(None) + } + "SY" | "SqrtY" | "R" => { + self.inner.sy(q); + Ok(None) + } + "SYdg" | "SqrtYdg" | "SqrtYd" | "Rd" => { + self.inner.sydg(q); + Ok(None) + } "S" | "SZ" | "SqrtZ" => { self.inner.sz(q); Ok(None) } - "Sd" | "SZdg" | "SqrtZdg" => { + "Sd" | "SZdg" | "SqrtZdg" | "SqrtZd" => { self.inner.szdg(q); Ok(None) } @@ -408,6 +432,34 @@ impl PyStabMps { self.inner.cz(pair); Ok(None) } + "SXX" => { + self.inner.sxx(pair); + Ok(None) + } + "SXXdg" => { + self.inner.sxxdg(pair); + Ok(None) + } + "SYY" => { + self.inner.syy(pair); + Ok(None) + } + "SYYdg" => { + self.inner.syydg(pair); + Ok(None) + } + "SZZ" => { + self.inner.szz(pair); + Ok(None) + } + "SZZdg" => { + self.inner.szzdg(pair); + Ok(None) + } + "SWAP" => { + self.inner.swap(pair); + Ok(None) + } "RZZ" => { let angle = crate::extract_angle(params, "RZZ")?; self.inner.rzz(angle, pair); diff --git a/python/pecos-rslib-exp/src/stabmps_builder.rs b/python/pecos-rslib-exp/src/stabmps_builder.rs new file mode 100644 index 000000000..b4cf0a044 --- /dev/null +++ b/python/pecos-rslib-exp/src/stabmps_builder.rs @@ -0,0 +1,119 @@ +// 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. + +//! `StabMps` backend for `sim_neo`. +//! +//! Provides a `SimulatorFactory` implementation that creates `StabMps` simulators +//! with configurable parameters (`lazy_measure`, `max_bond_dim`, etc.). + +use pecos_neo::noise::ComposableNoiseModel; +use pecos_neo::program::{DynProgramRunner, ProgramRunner}; +use pecos_neo::tool::SimulatorFactory; +use pecos_stab_tn::stab_mps::StabMps; + +/// Configuration for the `StabMps` backend. +/// +/// Carries simulator parameters through the builder-of-builders pattern. +/// Implements `SimulatorFactory` so it can be used with `custom_backend()`. +#[derive(Debug, Clone)] +pub struct StabMpsBuilder { + /// Use lazy measurement (correct for non-Clifford, slower). + pub lazy_measure: bool, + /// Maximum MPS bond dimension. + pub max_bond_dim: usize, + /// Maximum truncation error for MPS compression. + /// None = disabled (library default, use fixed bond dim cap only). + pub max_truncation_error: Option, + /// Merge consecutive RZ on same qubit before decomposition. + pub merge_rz: bool, +} + +impl Default for StabMpsBuilder { + fn default() -> Self { + Self { + lazy_measure: false, + max_bond_dim: 64, + max_truncation_error: None, + merge_rz: false, + } + } +} + +impl StabMpsBuilder { + /// Create with default settings. + #[must_use] + pub fn new() -> Self { + Self::default() + } + + /// Enable lazy measurement (correct for non-Clifford states). + #[must_use] + pub fn with_lazy_measure(mut self, lazy: bool) -> Self { + self.lazy_measure = lazy; + self + } + + /// Set maximum bond dimension. + #[must_use] + pub fn with_max_bond_dim(mut self, bd: usize) -> Self { + self.max_bond_dim = bd; + self + } + + /// Set maximum truncation error. + #[must_use] + pub fn with_max_truncation_error(mut self, err: f64) -> Self { + self.max_truncation_error = Some(err); + self + } + + /// Enable RZ merging. + #[must_use] + pub fn with_merge_rz(mut self, merge: bool) -> Self { + self.merge_rz = merge; + self + } +} + +impl SimulatorFactory for StabMpsBuilder { + fn create_runner( + &self, + num_qubits: usize, + noise: Option, + seed: Option, + ) -> Box { + let mut builder = StabMps::builder(num_qubits); + if self.lazy_measure { + builder = builder.lazy_measure(true); + } + builder = builder.max_bond_dim(self.max_bond_dim); + if let Some(err) = self.max_truncation_error { + builder = builder.max_truncation_error(err); + } + if self.merge_rz { + builder = builder.merge_rz(true); + } + if let Some(s) = seed { + builder = builder.seed(s); + } + let sim = builder.build(); + + let mut runner = ProgramRunner::rotations(sim); + if let Some(n) = noise { + runner = runner.with_noise(n); + } + if let Some(s) = seed { + runner = runner.with_seed(s); + } + Box::new(runner) + } +} diff --git a/python/pecos-rslib-llvm/pyproject.toml b/python/pecos-rslib-llvm/pyproject.toml index de6566bad..553757192 100644 --- a/python/pecos-rslib-llvm/pyproject.toml +++ b/python/pecos-rslib-llvm/pyproject.toml @@ -42,9 +42,6 @@ test = [ "pytest>=9.0", ] -[tool.uv.sources] -pecos-rslib-llvm = { workspace = true } - [tool.ruff] lint.extend-select = ["S", "B", "PT"] lint.ignore = ["S101"] diff --git a/python/pecos-rslib/Cargo.toml b/python/pecos-rslib/Cargo.toml index fe1c44197..965296fe0 100644 --- a/python/pecos-rslib/Cargo.toml +++ b/python/pecos-rslib/Cargo.toml @@ -62,8 +62,10 @@ pecos-experimental.workspace = true # Third-party pyo3 = { workspace = true, features = ["extension-module", "abi3-py310", "generate-import-lib", "num-complex"] } +rayon.workspace = true rand.workspace = true ndarray.workspace = true +nalgebra.workspace = true num-complex.workspace = true parking_lot.workspace = true serde_json.workspace = true diff --git a/python/pecos-rslib/pecos_rslib.pyi b/python/pecos-rslib/pecos_rslib.pyi index f949198c5..afab181c1 100644 --- a/python/pecos-rslib/pecos_rslib.pyi +++ b/python/pecos-rslib/pecos_rslib.pyi @@ -18,9 +18,11 @@ from __future__ import annotations import os from typing import ( + Any, Callable, Generic, Iterator, + Mapping, Sequence, TypeVar, overload, @@ -1024,7 +1026,42 @@ class Pauli: class PauliString: """String of Pauli operators.""" - ... + def __init__( + self, + paulis: list[tuple[Pauli, int]] | list[Pauli] | None = None, + phase: int = 0, + ) -> None: ... + @staticmethod + def from_str(s: str) -> PauliString: ... + @staticmethod + def from_dense_str(s: str) -> PauliString: ... + @staticmethod + def from_sparse_str(s: str) -> PauliString: ... + @staticmethod + def X(qubit: int) -> PauliString: ... + @staticmethod + def Y(qubit: int) -> PauliString: ... + @staticmethod + def Z(qubit: int) -> PauliString: ... + @staticmethod + def I() -> PauliString: ... # noqa: E743 + def to_dense_str(self, num_qubits: int | None = None) -> str: ... + def to_sparse_str(self) -> str: ... + def get_phase(self) -> int: ... + def get_paulis(self) -> list[tuple[Pauli, int]]: ... + def to_matrix(self, num_qubits: int | None = None) -> list[list[tuple[float, float]]]: ... + def commutes_with(self, other: PauliString) -> bool: ... + def anticommutes_with(self, other: PauliString) -> bool: ... + def weight(self) -> int: ... + def qubits(self) -> list[int]: ... + def __and__(self, other: PauliString) -> PauliString: ... + def __mul__(self, other: PauliString) -> PauliString: ... + def __neg__(self) -> PauliString: ... + def __eq__(self, other: object) -> bool: ... + +def X(qubit: int) -> PauliString: ... +def Y(qubit: int) -> PauliString: ... +def Z(qubit: int) -> PauliString: ... class PauliStabilizerGroup: """A group of commuting Pauli operators with real phases.""" @@ -1034,7 +1071,7 @@ class PauliStabilizerGroup: class PauliSequence: """Ordered sequence of Pauli operators with symplectic analysis.""" - ... + def group_commuting(self) -> list[PauliSequence]: ... class CliffordRep: """Clifford gate in the Heisenberg picture.""" @@ -1152,6 +1189,134 @@ class PauliPropRs: ... +ComplexMatrix = Sequence[Sequence[complex]] +RealMatrix = Sequence[Sequence[float]] +PauliProbabilityMap = Mapping[str | PauliString, float] | Sequence[tuple[str | PauliString, float]] + +class PauliChannel: + """Sparse Pauli error channel represented by probabilities.""" + + @staticmethod + def one_qubit(px: float, py: float, pz: float) -> PauliChannel: ... + @staticmethod + def from_probabilities( + num_qubits: int, + probabilities: PauliProbabilityMap, + ) -> PauliChannel: ... + def num_qubits(self) -> int: ... + def probabilities(self) -> dict[str, float]: ... + def total_error_rate(self) -> float: ... + def to_ptm(self) -> Ptm: ... + +class Ptm: + """Dense Pauli transfer matrix.""" + + def __init__(self, num_qubits: int, matrix: RealMatrix) -> None: ... + @staticmethod + def identity(num_qubits: int) -> Ptm: ... + def num_qubits(self) -> int: ... + def matrix(self) -> list[list[float]]: ... + def entry(self, output: int, input: int) -> float: ... + def to_choi(self) -> ChoiMatrix: ... + def to_kraus(self) -> KrausOps: ... + def to_superop(self) -> SuperOp: ... + def to_chi(self) -> ChiMatrix: ... + +class KrausOps: + """Kraus-operator channel representation.""" + + def __init__(self, num_qubits: int, operators: Sequence[ComplexMatrix]) -> None: ... + def num_qubits(self) -> int: ... + def operators(self) -> list[list[list[complex]]]: ... + def is_trace_preserving(self) -> bool: ... + def to_ptm(self) -> Ptm: ... + def to_choi(self) -> ChoiMatrix: ... + def to_superop(self) -> SuperOp: ... + def to_chi(self) -> ChiMatrix: ... + def to_stinespring(self) -> Stinespring: ... + +class ChoiMatrix: + """Choi-matrix channel representation.""" + + def __init__(self, num_qubits: int, matrix: ComplexMatrix) -> None: ... + def num_qubits(self) -> int: ... + def matrix(self) -> list[list[complex]]: ... + def apply_to_operator(self, operator: ComplexMatrix) -> list[list[complex]]: ... + def partial_trace_output(self) -> list[list[complex]]: ... + def partial_trace_input(self) -> list[list[complex]]: ... + def is_completely_positive(self) -> bool: ... + def is_trace_preserving(self) -> bool: ... + def is_cptp(self) -> bool: ... + def is_unital(self) -> bool: ... + def to_ptm(self) -> Ptm: ... + def to_kraus(self) -> KrausOps: ... + def to_superop(self) -> SuperOp: ... + def to_chi(self) -> ChiMatrix: ... + +class SuperOp: + """Column-stacked superoperator channel representation.""" + + def __init__(self, num_qubits: int, matrix: ComplexMatrix) -> None: ... + def num_qubits(self) -> int: ... + def matrix(self) -> list[list[complex]]: ... + def to_choi(self) -> ChoiMatrix: ... + def to_ptm(self) -> Ptm: ... + def to_kraus(self) -> KrausOps: ... + def compose(self, other: SuperOp) -> SuperOp: ... + def tensor(self, other: SuperOp) -> SuperOp: ... + +class ChiMatrix: + """Pauli-basis process matrix.""" + + def __init__(self, num_qubits: int, matrix: ComplexMatrix) -> None: ... + def num_qubits(self) -> int: ... + def matrix(self) -> list[list[complex]]: ... + def to_choi(self) -> ChoiMatrix: ... + def to_ptm(self) -> Ptm: ... + +class Stinespring: + """Stinespring isometry channel representation.""" + + def __init__(self, num_qubits: int, isometry: ComplexMatrix) -> None: ... + def num_qubits(self) -> int: ... + def environment_dim(self) -> int: ... + def isometry(self) -> list[list[complex]]: ... + def to_kraus(self) -> KrausOps: ... + def to_choi(self) -> ChoiMatrix: ... + def to_superop(self) -> SuperOp: ... + +def state_fidelity(left: Sequence[complex], right: Sequence[complex]) -> float: ... +def state_fidelity_with_density_matrix(rho: ComplexMatrix, psi: Sequence[complex]) -> float: ... +def purity(rho: ComplexMatrix) -> float: ... +def entropy(rho: ComplexMatrix) -> float: ... +def shannon_entropy(probabilities: Sequence[float], base: float) -> float: ... +def negativity(rho: ComplexMatrix, dims: Sequence[int], subsystem: int) -> float: ... +def logarithmic_negativity(rho: ComplexMatrix, dims: Sequence[int], subsystem: int) -> float: ... +def schmidt_decomposition( + state: Sequence[complex], + dims: Sequence[int], + left_subsystems: Sequence[int], +) -> list[tuple[float, list[complex], list[complex]]]: ... +def partial_trace_subsystems( + rho: ComplexMatrix, + dims: Sequence[int], + traced_subsystems: Sequence[int], +) -> list[list[complex]]: ... +def partial_trace_qubits( + rho: ComplexMatrix, + num_qubits: int, + traced_qubits: Sequence[int], +) -> list[list[complex]]: ... +def hellinger_distance(left: Sequence[float], right: Sequence[float]) -> float: ... +def hellinger_fidelity(left: Sequence[float], right: Sequence[float]) -> float: ... +def process_fidelity(left: Ptm, right: Ptm) -> float: ... +def average_gate_fidelity(left: Ptm, right: Ptm) -> float: ... +def gate_error(left: Ptm, right: Ptm) -> float: ... +def pauli_channel_diamond_norm(left: PauliChannel, right: PauliChannel) -> float: ... +def pauli_channel_diamond_distance(left: PauliChannel, right: PauliChannel) -> float: ... +def random_density_matrix(num_qubits: int, seed: int) -> list[list[complex]]: ... +def random_quantum_channel(num_qubits: int, num_kraus: int, seed: int) -> KrausOps: ... + # ============================================================================= # Clifford Gate Constructors # ============================================================================= @@ -1228,6 +1393,288 @@ class llvm: ... +# ============================================================================= +# Tick Circuit +# ============================================================================= + +class GateType: + """Gate type marker.""" + + H: GateType + X: GateType + Y: GateType + Z: GateType + S: GateType + Sdg: GateType + SX: GateType + SXdg: GateType + SY: GateType + SYdg: GateType + T: GateType + Tdg: GateType + I: GateType + CX: GateType + CY: GateType + CZ: GateType + RX: GateType + RY: GateType + RZ: GateType + RXX: GateType + RYY: GateType + RZZ: GateType + R1XY: GateType + U: GateType + F: GateType + Fdg: GateType + SXX: GateType + SXXdg: GateType + SYY: GateType + SYYdg: GateType + SZZ: GateType + SZZdg: GateType + SWAP: GateType + CH: GateType + CRZ: GateType + CCX: GateType + Measure: GateType + MeasureFree: GateType + Prep: GateType + QAlloc: GateType + QFree: GateType + TrackedPauliMeta: GateType + Custom: GateType + + @property + def name(self) -> str: ... + +class Gate: + """Quantum gate or stored TickCircuit gate batch.""" + + def __init__( + self, + gate_type: GateType, + params: Sequence[float] | None = None, + qubits: Sequence[int] | None = None, + ) -> None: ... + @property + def gate_type(self) -> GateType: ... + @property + def qubits(self) -> list[int]: ... + @property + def params(self) -> list[float]: ... + @property + def angles(self) -> list[float]: ... + @property + def meas_ids(self) -> list[int]: ... + def is_single_qubit(self) -> bool: ... + def is_two_qubit(self) -> bool: ... + def is_channel(self) -> bool: ... + def channel_mixed_pauli_terms(self) -> list[Any]: ... + @staticmethod + def h(qubits: Sequence[int]) -> Gate: ... + @staticmethod + def x(qubits: Sequence[int]) -> Gate: ... + @staticmethod + def y(qubits: Sequence[int]) -> Gate: ... + @staticmethod + def z(qubits: Sequence[int]) -> Gate: ... + @staticmethod + def cx(pairs: Sequence[tuple[int, int]]) -> Gate: ... + @staticmethod + def cy(pairs: Sequence[tuple[int, int]]) -> Gate: ... + @staticmethod + def cz(pairs: Sequence[tuple[int, int]]) -> Gate: ... + @staticmethod + def mz(qubits: Sequence[int]) -> Gate: ... + @staticmethod + def pz(qubits: Sequence[int]) -> Gate: ... + +class DagCircuit: + """Directed acyclic graph circuit representation.""" + + def __init__(self) -> None: ... + def gate_count(self) -> int: ... + def gate(self, node: int) -> Gate | None: ... + def nodes(self) -> list[int]: ... + def to_tick_circuit(self) -> TickCircuit: ... + +class Tick: + """A single time slice of a tick-based quantum circuit.""" + + def __len__(self) -> int: ... + def gate_count(self) -> int: + """Number of individual gate applications in this tick.""" + ... + + def gate_batch_count(self) -> int: + """Number of stored compatible gate batches in this tick.""" + ... + + def is_empty(self) -> bool: ... + def gate_batches(self) -> list[Gate]: ... + def get_gate_attr(self, gate_idx: int, key: str) -> Any | None: ... + def set_gate_attr(self, gate_idx: int, key: str, value: Any) -> None: ... + def set_gate_attrs(self, gate_idx: int, attrs: Mapping[str, Any]) -> None: ... + def get_attr(self, key: str) -> Any | None: ... + def meta(self, key: str, value: Any) -> None: ... + def metas(self, attrs: Mapping[str, Any]) -> None: ... + def active_qubits(self) -> list[int]: ... + def uses_qubit(self, qubit: int) -> bool: ... + def find_conflicts(self, qubits: Sequence[int]) -> list[Any]: ... + def add_gate(self, gate: Gate) -> int: ... + def try_add_gate(self, gate: Gate) -> int: ... + def discard(self, qubits: Sequence[int]) -> int: ... + def remove_gate(self, idx: int) -> Gate | None: ... + +class TickHandle: + """Handle for adding gates to a tick.""" + + def index(self) -> int: ... + def meta(self, key: str, value: Any) -> TickHandle: ... + def metas(self, attrs: Mapping[str, Any]) -> TickHandle: ... + def h(self, qubits: Sequence[int]) -> TickHandle: ... + def x(self, qubits: Sequence[int]) -> TickHandle: ... + def y(self, qubits: Sequence[int]) -> TickHandle: ... + def z(self, qubits: Sequence[int]) -> TickHandle: ... + def i(self, qubits: Sequence[int]) -> TickHandle: ... + def sx(self, qubits: Sequence[int]) -> TickHandle: ... + def sxdg(self, qubits: Sequence[int]) -> TickHandle: ... + def sy(self, qubits: Sequence[int]) -> TickHandle: ... + def sydg(self, qubits: Sequence[int]) -> TickHandle: ... + def sz(self, qubits: Sequence[int]) -> TickHandle: ... + def szdg(self, qubits: Sequence[int]) -> TickHandle: ... + def t(self, qubits: Sequence[int]) -> TickHandle: ... + def tdg(self, qubits: Sequence[int]) -> TickHandle: ... + def rx(self, theta: Any, qubits: Sequence[int]) -> TickHandle: ... + def ry(self, theta: Any, qubits: Sequence[int]) -> TickHandle: ... + def rz(self, theta: Any, qubits: Sequence[int]) -> TickHandle: ... + def r1xy(self, theta: Any, phi: Any, qubits: Sequence[int]) -> TickHandle: ... + def u(self, theta: Any, phi: Any, lam: Any, qubits: Sequence[int]) -> TickHandle: ... + def cx(self, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def cy(self, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def cz(self, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def szz(self, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def szzdg(self, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def f(self, qubits: Sequence[int]) -> TickHandle: ... + def fdg(self, qubits: Sequence[int]) -> TickHandle: ... + def sxx(self, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def sxxdg(self, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def syy(self, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def syydg(self, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def swap(self, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def ch(self, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def crz(self, theta: Any, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def ccx(self, triples: Sequence[tuple[int, int, int]]) -> TickHandle: ... + def rxx(self, theta: Any, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def ryy(self, theta: Any, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def rzz(self, theta: Any, pairs: Sequence[tuple[int, int]]) -> TickHandle: ... + def add_gate( + self, + name: str, + qubits: Sequence[int], + angles: Sequence[float] | None = None, + ) -> TickHandle: ... + def custom(self, qubits: Sequence[int]) -> TickHandle: ... + def custom_gate( + self, + name: str, + qubits: Sequence[int], + angles: Sequence[float] | None = None, + ) -> TickHandle: ... + def pz(self, qubits: Sequence[int]) -> TickPrepHandle: ... + def mz(self, qubits: Sequence[int]) -> list[tuple[int, int, int]]: ... + def mz_with_ids( + self, + qubits: Sequence[int], + meas_ids: Sequence[int], + ) -> list[tuple[int, int, int]]: ... + def mz_free(self, qubits: Sequence[int]) -> list[tuple[int, int, int]]: ... + def qalloc(self, qubits: Sequence[int]) -> TickHandle: ... + def qfree(self, qubits: Sequence[int]) -> TickHandle: ... + def idle(self, duration: Any, qubits: Sequence[int]) -> TickHandle: ... + +class TickPrepHandle: + """Handle returned by preparation gates for attaching metadata.""" + + def meta(self, key: str, value: Any) -> None: ... + def metas(self, attrs: Mapping[str, Any]) -> None: ... + +class TickMeasureHandle: + """Handle returned by measurement gates for attaching metadata.""" + + def meta(self, key: str, value: Any) -> None: ... + def metas(self, attrs: Mapping[str, Any]) -> None: ... + +class TickCircuit: + """Tick-based quantum circuit representation.""" + + def __init__(self) -> None: ... + def tick(self) -> TickHandle: + """Add a new tick and return a handle for adding gates.""" + ... + + def num_ticks(self) -> int: ... + def gate_count(self) -> int: + """Total number of individual gate applications.""" + ... + + def gate_batch_count(self) -> int: + """Total number of stored compatible gate batches.""" + ... + + def num_measurements(self) -> int: ... + def next_tick_index(self) -> int: ... + def get_tick(self, idx: int) -> Tick | None: ... + def tick_at(self, idx: int) -> TickHandle: ... + def insert_tick(self, idx: int) -> TickHandle: ... + def gate_batches(self) -> list[tuple[int, Gate]]: ... + def all_qubits(self) -> list[int]: ... + def gate_counts_by_type(self) -> dict[str, int]: ... + def set_meta(self, key: str, value: Any) -> None: ... + def metas(self, attrs: Mapping[str, Any]) -> None: ... + def get_meta(self, key: str) -> Any | None: ... + def add_detector( + self, + records: Sequence[int], + coords: Sequence[float] | None = None, + label: str | None = None, + detector_id: int | None = None, + ) -> int: ... + def add_observable( + self, + records: Sequence[int], + observable_id: int | None = None, + label: str | None = None, + ) -> int: ... + def annotations(self) -> list[Any]: ... + def detector(self, measurements: Any, label: str | None = None) -> int: ... + def observable(self, measurements: Any, label: str | None = None) -> int: ... + def tracked_pauli(self, pauli: PauliString, label: str | None = None) -> int: ... + def clear(self) -> None: ... + def reset(self) -> None: ... + def reserve_ticks(self, n: int) -> None: ... + def discard(self, qubits: Sequence[int], tick_idx: int) -> int | None: ... + def set_tick_meta(self, tick_idx: int, key: str, value: Any) -> None: ... + def get_tick_meta(self, tick_idx: int, key: str) -> Any | None: ... + 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 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: ... + def with_noise( + self, + p1: float = 0.0, + p2: float = 0.0, + p_meas: float = 0.0, + p_prep: float = 0.0, + ) -> TickCircuit: ... + def compact_ticks(self) -> None: ... + def import_gate_signatures(self, sigs: Mapping[str, tuple[int, int]]) -> None: ... + def gate_signatures(self) -> dict[str, tuple[int, int]]: ... + def import_registry(self, registry: Any) -> None: ... + def to_dag_circuit(self) -> DagCircuit: ... + # ============================================================================= # Namespace Modules # ============================================================================= @@ -1247,6 +1694,45 @@ class quantum: DensityMatrixEngineBuilder: type[DensityMatrixEngineBuilder] CoinTossEngineBuilder: type[CoinTossEngineBuilder] +class quantum_info: + """Quantum-information channel and measure namespace.""" + + PauliChannel: type[PauliChannel] + Ptm: type[Ptm] + KrausOps: type[KrausOps] + ChoiMatrix: type[ChoiMatrix] + SuperOp: type[SuperOp] + ChiMatrix: type[ChiMatrix] + Stinespring: type[Stinespring] + state_fidelity: Callable[[Sequence[complex], Sequence[complex]], float] + state_fidelity_with_density_matrix: Callable[[ComplexMatrix, Sequence[complex]], float] + purity: Callable[[ComplexMatrix], float] + entropy: Callable[[ComplexMatrix], float] + shannon_entropy: Callable[[Sequence[float], float], float] + negativity: Callable[[ComplexMatrix, Sequence[int], int], float] + logarithmic_negativity: Callable[[ComplexMatrix, Sequence[int], int], float] + schmidt_decomposition: Callable[ + [Sequence[complex], Sequence[int], Sequence[int]], + list[tuple[float, list[complex], list[complex]]], + ] + partial_trace_subsystems: Callable[ + [ComplexMatrix, Sequence[int], Sequence[int]], + list[list[complex]], + ] + partial_trace_qubits: Callable[ + [ComplexMatrix, int, Sequence[int]], + list[list[complex]], + ] + hellinger_distance: Callable[[Sequence[float], Sequence[float]], float] + hellinger_fidelity: Callable[[Sequence[float], Sequence[float]], float] + process_fidelity: Callable[[Ptm, Ptm], float] + average_gate_fidelity: Callable[[Ptm, Ptm], float] + gate_error: Callable[[Ptm, Ptm], float] + pauli_channel_diamond_norm: Callable[[PauliChannel, PauliChannel], float] + pauli_channel_diamond_distance: Callable[[PauliChannel, PauliChannel], float] + random_density_matrix: Callable[[int, int], list[list[complex]]] + random_quantum_channel: Callable[[int, int, int], KrausOps] + class noise: """Noise model namespace.""" diff --git a/python/pecos-rslib/src/dag_circuit_bindings.rs b/python/pecos-rslib/src/dag_circuit_bindings.rs index 02ea7062b..31bd12985 100644 --- a/python/pecos-rslib/src/dag_circuit_bindings.rs +++ b/python/pecos-rslib/src/dag_circuit_bindings.rs @@ -24,11 +24,87 @@ use crate::dtypes::AngleParam; use crate::gate_registry_bindings::PyGateRegistry; -use pecos_core::{Angle64, GateQubits, GateSignature, TimeUnits}; -use pecos_quantum::{Attribute, DagCircuit, Gate, GateType, QubitId, Tick, TickCircuit}; +use pecos_core::{Angle64, ChannelExpr, GateQubits, GateSignature, Pauli, TimeUnits}; +use pecos_quantum::{ + Attribute, DagCircuit, Gate, GateType, QubitId, Tick, TickCircuit, TickGateError, +}; use pyo3::prelude::*; use pyo3::types::{PyBytes, PyDict, PyList}; -use std::collections::HashMap; + +type PyMixedPauliTerm = (f64, Vec<(String, usize)>); + +fn pauli_string_terms(pauli: &pecos_core::PauliString) -> Vec<(String, usize)> { + pauli + .paulis() + .iter() + .map(|(p, q)| { + let label = match p { + Pauli::I => "I", + Pauli::X => "X", + Pauli::Y => "Y", + Pauli::Z => "Z", + }; + (label.to_string(), q.index()) + }) + .collect() +} + +fn mixed_pauli_terms(channel: &ChannelExpr) -> PyResult> { + match channel { + ChannelExpr::Unitary(unitary) => { + let pauli = unitary.clone().try_to_pauli_string().ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "channel unitary is not representable as a Pauli operator", + ) + })?; + Ok(vec![(1.0, pauli_string_terms(&pauli))]) + } + ChannelExpr::MixedUnitary(ops) => ops + .iter() + .map(|(prob, unitary)| { + let pauli = unitary.clone().try_to_pauli_string().ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err( + "mixed-unitary channel contains a non-Pauli unitary", + ) + })?; + Ok((*prob, pauli_string_terms(&pauli))) + }) + .collect(), + _ => Err(pyo3::exceptions::PyValueError::new_err( + "channel is not a Pauli mixed-unitary channel", + )), + } +} + +fn validate_probability(name: &str, p: f64) -> PyResult<()> { + if (0.0..=1.0).contains(&p) { + Ok(()) + } else { + Err(pyo3::exceptions::PyValueError::new_err(format!( + "{name} must be in [0, 1], got {p}" + ))) + } +} + +fn receives_two_qubit_noise(gate_type: GateType) -> bool { + matches!( + gate_type, + GateType::CX + | GateType::CY + | GateType::CZ + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg + | GateType::SWAP + | GateType::CRZ + | GateType::RXX + | GateType::RYY + | GateType::RZZ + ) +} /// Convert a Rust Attribute to a Python object. fn attribute_to_py(py: Python<'_>, attr: &Attribute) -> Py { @@ -529,6 +605,14 @@ impl PyGateType { } } + #[classattr] + #[pyo3(name = "TrackedPauliMeta")] + fn tracked_pauli_meta() -> Self { + Self { + inner: GateType::TrackedPauliMeta, + } + } + #[classattr] #[pyo3(name = "Custom")] fn custom() -> Self { @@ -619,6 +703,12 @@ impl PyGate { self.inner.qubits.iter().map(|q| usize::from(*q)).collect() } + /// Measurement result identities (one per qubit for measurement gates, empty otherwise). + #[getter] + fn meas_ids(&self) -> Vec { + self.inner.meas_ids.iter().map(|mr| mr.0).collect() + } + /// Check if this is a single-qubit gate. fn is_single_qubit(&self) -> bool { self.inner.is_single_qubit() @@ -629,6 +719,22 @@ impl PyGate { self.inner.is_two_qubit() } + /// Check if this gate carries a channel payload. + fn is_channel(&self) -> bool { + self.inner.is_channel() + } + + /// Return a Pauli mixed-unitary channel payload as `(probability, terms)`. + /// + /// Each term is a list of `(pauli, qubit)` pairs. Identity terms are + /// represented by an empty list. Non-Pauli channels raise `ValueError`. + fn channel_mixed_pauli_terms(&self) -> PyResult> { + let channel = self.inner.channel_expr().ok_or_else(|| { + pyo3::exceptions::PyValueError::new_err("gate does not carry a channel payload") + })?; + mixed_pauli_terms(channel) + } + // Factory methods for common gates /// Create a Hadamard gate. @@ -866,6 +972,27 @@ pyo3::create_exception!( pyo3::exceptions::PyException ); +/// Extract node indices from a list of either `int` or `(int, int)` tuples. +/// This allows `detector()` / `observable()` to accept both raw node indices +/// and measurement refs from `mz()`. +fn extract_measurement_nodes(list: &Bound<'_, pyo3::types::PyList>) -> PyResult> { + list.iter() + .map(|item| { + // Try (node, qubit) tuple first + if let Ok((node, _qubit)) = item.extract::<(usize, usize)>() { + Ok(node) + } else { + // Fall back to plain int + item.extract::().map_err(|_| { + pyo3::exceptions::PyTypeError::new_err( + "measurements must be a list of ints or (node, qubit) tuples from mz()", + ) + }) + } + }) + .collect() +} + /// Python wrapper for `DagCircuit`. /// /// A directed acyclic graph representation of a quantum circuit where nodes are gates @@ -897,8 +1024,10 @@ impl PyDagCircuit { /// Add a gate to the circuit. /// /// Returns the node index of the newly added gate. - fn add_gate(&mut self, gate: PyGate) -> usize { - self.inner.add_gate(gate.inner) + fn add_gate(&mut self, gate: PyGate) -> PyResult { + self.inner + .try_add_gate(gate.inner) + .map_err(pyo3::exceptions::PyValueError::new_err) } /// Remove a gate from the circuit. @@ -1246,11 +1375,16 @@ impl PyDagCircuit { /// Measure qubits in the Z basis. /// - /// Note: Unlike gates, measurements break the chain in simulators. - /// This method still returns self for convenience in Python. - fn mz(slf: Py, py: Python<'_>, qubits: Vec) -> Py { - slf.borrow_mut(py).inner.mz(&qubits); - slf + /// Returns a list of `(node, qubit)` tuples that can be passed to + /// `detector()` and `observable()`. + /// + /// Example: + /// >>> dag = `DagCircuit()` + /// >>> ms = dag.mz([0, 1]) + /// >>> dag.detector(ms) + fn mz(slf: &Bound<'_, Self>, qubits: Vec) -> Vec<(usize, usize)> { + let refs = slf.borrow_mut().inner.mz(&qubits); + refs.iter().map(|r| (r.node, r.qubit.index())).collect() } /// Measure and free qubits (destructive measurement). @@ -1277,6 +1411,113 @@ impl PyDagCircuit { slf } + // ==================== Annotations ==================== + + /// Annotate a detector: measurements whose XOR should be deterministic. + /// + /// Args: + /// measurements: List of measurement refs from `mz()` (tuples of + /// `(node, qubit)`), or plain node indices. + /// label: Optional label string. + /// + /// Returns: + /// The annotation index. + /// + /// Example: + /// >>> ms = dag.mz([0, 1]) + /// >>> dag.detector(ms, `label="Z_0` `Z_1`") + #[pyo3(signature = (measurements, label=None))] + fn detector( + &mut self, + measurements: &Bound<'_, pyo3::types::PyList>, + label: Option, + ) -> PyResult { + let nodes = extract_measurement_nodes(measurements)?; + Ok(if let Some(l) = label { + self.inner.detector_labeled(&l, &nodes) + } else { + self.inner.detector(&nodes) + }) + } + + /// Annotate a logical observable: measurements whose XOR gives a logical outcome. + /// + /// Args: + /// measurements: List of measurement refs from `mz()` (tuples of + /// `(node, qubit)`), or plain node indices. + /// label: Optional label string. + /// + /// Returns: + /// The annotation index. + #[pyo3(signature = (measurements, label=None))] + fn observable( + &mut self, + measurements: &Bound<'_, pyo3::types::PyList>, + label: Option, + ) -> PyResult { + let nodes = extract_measurement_nodes(measurements)?; + Ok(if let Some(l) = label { + self.inner.observable_labeled(&l, &nodes) + } else { + self.inner.observable(&nodes) + }) + } + + /// Place a tracked-Pauli annotation at this point in the circuit. + /// + /// Only faults BEFORE this annotation can flip the operator. + /// Accepts a `PauliString`, which supports `PauliString.X(0) & PauliString.Z(1)`. + /// + /// Args: + /// pauli: A `PauliString` to track. + /// label: Optional label string. + /// + /// Returns: + /// The annotation index. + /// + /// Example: + /// >>> from pecos import PauliString + /// >>> dag.tracked_pauli(PauliString.Z(0) & PauliString.Z(1)) + #[pyo3(signature = (pauli, label=None))] + fn tracked_pauli( + &mut self, + pauli: &crate::pauli_bindings::PauliString, + label: Option, + ) -> usize { + if let Some(l) = label { + self.inner.tracked_pauli_labeled(&l, pauli.inner.clone()) + } else { + self.inner.tracked_pauli(pauli.inner.clone()) + } + } + + /// Get all annotations as a list of dicts. + /// + /// Each dict has keys: "pauli" (`PauliString`), "kind" (str), "label" (str or None). + fn annotations(&self, py: Python<'_>) -> PyResult> { + let list = pyo3::types::PyList::empty(py); + + for ann in self.inner.annotations() { + let dict = pyo3::types::PyDict::new(py); + let ps = crate::pauli_bindings::PauliString { + inner: ann.pauli.clone(), + }; + dict.set_item("pauli", ps.into_pyobject(py)?)?; + let kind_str = match &ann.kind { + pecos_quantum::AnnotationKind::Detector { .. } => "detector", + pecos_quantum::AnnotationKind::Observable { .. } => "observable", + pecos_quantum::AnnotationKind::TrackedPauli => "tracked_pauli", + }; + dict.set_item("kind", kind_str)?; + dict.set_item("label", &ann.label)?; + list.append(dict)?; + } + + Ok(list.unbind()) + } + + // ==================== Metadata ==================== + /// Add metadata to the last added gate. /// /// Args: @@ -1495,6 +1736,28 @@ pyo3::create_exception!( pyo3::exceptions::PyValueError ); +fn tick_gate_error_to_pyerr(err: TickGateError, tick_idx: Option) -> PyErr { + match err { + TickGateError::QubitConflict(mut err) => { + if let Some(tick_idx) = tick_idx { + err.tick_idx = Some(tick_idx); + } + PyErr::new::(err.to_string()) + } + TickGateError::InvalidGate { + message, + tick_idx: err_tick_idx, + } => { + let tick_idx = tick_idx.or(err_tick_idx); + let msg = match tick_idx { + Some(tick_idx) => format!("Invalid gate in tick {tick_idx}: {message}"), + None => format!("Invalid gate: {message}"), + }; + PyErr::new::(msg) + } + } +} + /// Convert HUGR bytes to a `DagCircuit`. /// /// This function takes serialized HUGR data (JSON or binary envelope format) @@ -1800,20 +2063,30 @@ pub struct PyTick { #[pymethods] impl PyTick { - /// Get the number of gates in this tick. + /// Get the number of stored gate batches in this tick. fn __len__(&self) -> usize { self.inner.len() } + /// Get the number of individual gate applications in this tick. + fn gate_count(&self) -> usize { + self.inner.gate_count() + } + + /// Get the number of compatible gate batches in this tick. + fn gate_batch_count(&self) -> usize { + self.inner.gate_batch_count() + } + /// Check if the tick is empty. fn is_empty(&self) -> bool { self.inner.is_empty() } - /// Get the gates in this tick as a list. - fn gates(&self) -> Vec { + /// Get the stored gate batches in this tick as a list. + fn gate_batches(&self) -> Vec { self.inner - .gates() + .gate_batches() .iter() .map(|g: &Gate| PyGate { inner: g.clone() }) .collect() @@ -1903,8 +2176,10 @@ impl PyTick { /// Add a gate to this tick. /// /// Returns the index of the added gate within this tick. - fn add_gate(&mut self, gate: &PyGate) -> usize { - self.inner.add_gate(gate.inner.clone()) + fn add_gate(&mut self, gate: &PyGate) -> PyResult { + self.inner + .try_add_gate(gate.inner.clone()) + .map_err(|e| tick_gate_error_to_pyerr(e, None)) } /// Try to add a gate to this tick, returning an error if any qubit is already in use. @@ -1916,7 +2191,7 @@ impl PyTick { fn try_add_gate(&mut self, gate: &PyGate) -> PyResult { self.inner .try_add_gate(gate.inner.clone()) - .map_err(|e| PyErr::new::(e.to_string())) + .map_err(|e| tick_gate_error_to_pyerr(e, None)) } /// Remove all gates that use any of the specified qubits. @@ -1947,7 +2222,8 @@ impl PyTick { /// Use `tick()` to create a new tick and get a handle for adding gates. #[pyclass(name = "TickCircuit", module = "pecos_rslib.quantum")] pub struct PyTickCircuit { - inner: TickCircuit, + /// The underlying Rust TickCircuit. + pub inner: TickCircuit, } #[pymethods] @@ -1970,6 +2246,16 @@ impl PyTickCircuit { self.inner.gate_count() } + /// Get the total number of compatible gate batches across all ticks. + fn gate_batch_count(&self) -> usize { + self.inner.gate_batch_count() + } + + /// Get the total number of measurement results produced so far. + fn num_measurements(&self) -> usize { + self.inner.num_measurements() + } + /// Get the next tick index that will be allocated. fn next_tick_index(&self) -> usize { self.inner.next_tick_index() @@ -2015,6 +2301,41 @@ impl PyTickCircuit { .map(|attr| attribute_to_py(py, attr)) } + /// Add detector metadata using measurement-record offsets. + /// + /// This is the typed equivalent of appending to the circuit-level + /// ``"detectors"`` JSON metadata list. Use ``detector(...)`` when you + /// already have explicit measurement handles from this TickCircuit. + #[pyo3(signature = (records, coords=None, label=None, detector_id=None))] + fn add_detector( + &mut self, + records: Vec, + coords: Option>, + label: Option, + detector_id: Option, + ) -> PyResult { + self.inner + .add_detector_metadata(&records, coords.as_deref(), label.as_deref(), detector_id) + .map_err(pyo3::exceptions::PyValueError::new_err) + } + + /// Add observable metadata using measurement-record offsets. + /// + /// Standard observables live in the ``L`` decoder ID space. A label of + /// ``"L3"`` therefore selects observable id 3 unless ``observable_id`` is + /// provided, in which case the two must agree. + #[pyo3(signature = (records, observable_id=None, label=None))] + fn add_observable( + &mut self, + records: Vec, + observable_id: Option, + label: Option, + ) -> PyResult { + self.inner + .add_observable_metadata(&records, observable_id, label.as_deref()) + .map_err(pyo3::exceptions::PyValueError::new_err) + } + // --- Circuit manipulation --- /// Clear the circuit and start fresh. @@ -2123,18 +2444,18 @@ impl PyTickCircuit { Ok(dict.into()) } - /// Get all gates in the circuit as a list. + /// Get all stored gate batches in the circuit as a list. /// /// Returns: /// A list of (`tick_index`, gate) tuples. - fn gates(&self) -> Vec<(usize, PyGate)> { + fn gate_batches(&self) -> Vec<(usize, PyGate)> { self.inner - .iter_gates_with_tick() + .iter_gate_batches_with_tick() .map(|(tick_idx, gate)| { ( tick_idx, PyGate { - inner: gate.clone(), + inner: gate.as_gate().clone(), }, ) }) @@ -2274,6 +2595,160 @@ impl PyTickCircuit { } } + /// Lower Clifford-angle rotations to named Clifford gates. + /// + /// Replaces parameterized rotations at Clifford angles with their named + /// equivalents: RZ(pi/2) -> SZ, RZ(pi) -> Z, RX(pi/2) -> SX, etc. + /// + /// Use this on circuits from QIS trace (Guppy/Selene) that use + /// parameterized gates even for Clifford operations. Without this, + /// stabilizer simulators may reject the circuit. + /// + /// Modifies the circuit in place. + fn lower_clifford_rotations(&mut self) { + use pecos_quantum::pass::{CircuitPass, SimplifyRotations}; + SimplifyRotations.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) + /// that don't assign MeasId during construction. + fn assign_missing_meas_ids(&mut self) { + use pecos_quantum::pass::{AssignMissingMeasIds, CircuitPass}; + AssignMissingMeasIds.apply_tick(&mut self.inner); + } + + /// Insert Idle gates after each two-qubit gate on both of its qubits. + /// + /// Models idle noise during two-qubit gate execution. The noise model + /// applies RZ(p_idle * duration) when it encounters an Idle gate. + /// + /// Args: + /// duration: Idle time in abstract time units (default: 1.0). + #[pyo3(signature = (duration=1.0))] + fn insert_idle_after_two_qubit_gates(&mut self, duration: f64) { + self.inner.insert_idle_after_two_qubit_gates(duration); + } + + /// Insert identity gates for qubits not operated on during each tick. + /// + /// For each tick, qubits not involved in any gate get an identity (I) + /// gate that receives `p1` noise. This matches Stim's convention of + /// `DEPOLARIZE1` on idle qubits between ticks. + fn fill_idle_gates(&mut self) { + self.inner.fill_idle_gates(); + } + + /// Return a new circuit with explicit Pauli channel operations inserted. + /// + /// This compiles gate-triggered quantum noise into inline channel gates. + /// Measurement readout noise is intentionally not represented here because + /// it is classical outcome noise, not a quantum channel after measurement. + #[pyo3(signature = (p1=0.0, p2=0.0, p_meas=0.0, p_prep=0.0))] + fn with_noise(&self, p1: f64, p2: f64, p_meas: f64, p_prep: f64) -> PyResult { + validate_probability("p1", p1)?; + validate_probability("p2", p2)?; + validate_probability("p_meas", p_meas)?; + validate_probability("p_prep", p_prep)?; + + if p_meas > 0.0 { + return Err(pyo3::exceptions::PyValueError::new_err( + "TickCircuit.with_noise inserts quantum channel operations and cannot represent classical measurement readout noise; use sim_neo(...).noise(...) for p_meas", + )); + } + + if p2 > 0.0 { + for (tick_idx, gate) in self.inner.iter_gate_batches_with_tick() { + if receives_two_qubit_noise(gate.gate_type) && !gate.qubits.len().is_multiple_of(2) + { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "{:?} at tick {tick_idx} has {} qubits; expected pairs", + gate.gate_type, + gate.qubits.len() + ))); + } + } + } + + let noisy = self + .inner + .try_with_noise(&|gate: &Gate| -> Vec { + let mut channels = Vec::new(); + match gate.gate_type { + GateType::PZ | GateType::QAlloc if p_prep > 0.0 => { + channels.extend( + gate.qubits + .iter() + .map(|q| pecos_core::channel::BitFlip(p_prep, q.index())), + ); + } + GateType::I + | GateType::X + | GateType::Y + | GateType::Z + | GateType::H + | GateType::F + | GateType::Fdg + | GateType::SX + | GateType::SXdg + | GateType::SY + | GateType::SYdg + | GateType::SZ + | GateType::SZdg + | GateType::T + | GateType::Tdg + | GateType::RX + | GateType::RY + | GateType::RZ + | GateType::U + | GateType::R1XY + | GateType::Idle + if p1 > 0.0 => + { + channels.extend( + gate.qubits + .iter() + .map(|q| pecos_core::channel::Depolarizing(p1, q.index())), + ); + } + GateType::CX + | GateType::CY + | GateType::CZ + | GateType::SZZ + | GateType::SZZdg + | GateType::SXX + | GateType::SXXdg + | GateType::SYY + | GateType::SYYdg + | GateType::SWAP + | GateType::CRZ + | GateType::RXX + | GateType::RYY + | GateType::RZZ + if p2 > 0.0 => + { + channels.extend(gate.qubits.chunks_exact(2).map(|pair| { + pecos_core::channel::Depolarizing2(p2, pair[0].index(), pair[1].index()) + })); + } + _ => {} + } + channels + }) + .map_err(pyo3::exceptions::PyValueError::new_err)?; + Ok(Self { inner: noisy }) + } + + /// Compact ticks by merging gates into earlier ticks when possible. + /// + /// ASAP scheduling: gates that don't share qubits are merged into the + /// same tick. Useful after replaying a serialized trace where each + /// gate got its own tick. + fn compact_ticks(&mut self) { + self.inner.compact_ticks(); + } + // --- Gate signature validation --- /// Import gate signatures for validation. @@ -2281,7 +2756,7 @@ impl PyTickCircuit { /// Args: /// sigs: A dictionary mapping gate names to (`quantum_arity`, `angle_arity`) tuples. fn import_gate_signatures(&mut self, sigs: &Bound<'_, PyDict>) -> PyResult<()> { - let mut sig_map = HashMap::new(); + let mut sig_map = std::collections::BTreeMap::new(); for (key, value) in sigs.iter() { let name: String = key.extract()?; let (quantum_arity, angle_arity): (usize, usize) = value.extract()?; @@ -2314,7 +2789,8 @@ impl PyTickCircuit { /// Extracts signatures from all registered gates and imports them /// for validation when adding custom gates. fn import_registry(&mut self, registry: &PyGateRegistry) { - let sigs = registry.inner.signatures(); + let sigs: std::collections::BTreeMap<_, _> = + registry.inner.signatures().into_iter().collect(); self.inner.import_signatures(&sigs); } @@ -2325,6 +2801,97 @@ impl PyTickCircuit { self.inner.gate_count() ) } + + // ==================== Annotations ==================== + + /// Annotate a detector: measurements whose XOR should be deterministic. + #[pyo3(signature = (measurements, label=None))] + fn detector( + &mut self, + measurements: &Bound<'_, pyo3::types::PyList>, + label: Option, + ) -> PyResult { + let refs = extract_tick_meas_refs(measurements)?; + let idx = if let Some(l) = label { + self.inner.detector_labeled(&l, &refs) + } else { + self.inner.detector(&refs) + }; + Ok(idx) + } + + /// Annotate a logical observable. + #[pyo3(signature = (measurements, label=None))] + fn observable( + &mut self, + measurements: &Bound<'_, pyo3::types::PyList>, + label: Option, + ) -> PyResult { + let refs = extract_tick_meas_refs(measurements)?; + let idx = if let Some(l) = label { + self.inner.observable_labeled(&l, &refs) + } else { + self.inner.observable(&refs) + }; + Ok(idx) + } + + /// Place a tracked-Pauli annotation. + #[pyo3(signature = (pauli, label=None))] + fn tracked_pauli( + &mut self, + pauli: &crate::pauli_bindings::PauliString, + label: Option, + ) -> usize { + if let Some(l) = label { + self.inner.tracked_pauli_labeled(&l, pauli.inner.clone()) + } else { + self.inner.tracked_pauli(pauli.inner.clone()) + } + } + + /// Get all annotations. + fn annotations(&self, py: Python<'_>) -> PyResult> { + let list = pyo3::types::PyList::empty(py); + for ann in self.inner.annotations() { + let dict = pyo3::types::PyDict::new(py); + let ps = crate::pauli_bindings::PauliString { + inner: ann.pauli.clone(), + }; + dict.set_item("pauli", ps.into_pyobject(py)?)?; + let kind_str = match &ann.kind { + pecos_quantum::AnnotationKind::Detector { .. } => "detector", + pecos_quantum::AnnotationKind::Observable { .. } => "observable", + pecos_quantum::AnnotationKind::TrackedPauli => "tracked_pauli", + }; + dict.set_item("kind", kind_str)?; + dict.set_item("label", &ann.label)?; + list.append(dict)?; + } + Ok(list.unbind()) + } +} + +/// Extract `TickMeasRef` from Python list of `(tick, gate_idx, qubit)` tuples. +fn extract_tick_meas_refs( + list: &Bound<'_, pyo3::types::PyList>, +) -> PyResult> { + list.iter() + .map(|item| { + let (tick, gate_idx, qubit): (usize, usize, usize) = item.extract().map_err(|_| { + pyo3::exceptions::PyTypeError::new_err( + "measurements must be (tick, gate_idx, qubit) tuples from mz()", + ) + })?; + Ok(pecos_quantum::TickMeasRef { + tick, + gate_idx, + qubit: pecos_core::QubitId::from(qubit), + record_idx: 0, // Populated by TickCircuit; placeholder for external construction + meas_id: pecos_core::MeasId(0), // Placeholder + }) + }) + .collect() } /// Handle to a specific tick for adding gates. @@ -2361,17 +2928,7 @@ impl PyTickHandle { self.last_gate_idx = Some(idx); Ok(()) } - Err(err) => { - let msg = format!( - "Qubit(s) {:?} already in use in tick {}", - err.conflicting_qubits - .iter() - .map(std::string::ToString::to_string) - .collect::>(), - self.tick_idx - ); - Err(PyErr::new::(msg)) - } + Err(err) => Err(tick_gate_error_to_pyerr(err, Some(self.tick_idx))), } } else { Ok(()) @@ -2386,17 +2943,7 @@ impl PyTickHandle { self.last_gate_idx = Some(idx); Ok(idx) } - Err(err) => { - let msg = format!( - "Qubit(s) {:?} already in use in tick {}", - err.conflicting_qubits - .iter() - .map(std::string::ToString::to_string) - .collect::>(), - self.tick_idx - ); - Err(PyErr::new::(msg)) - } + Err(err) => Err(tick_gate_error_to_pyerr(err, Some(self.tick_idx))), } } else { Ok(0) @@ -2927,17 +3474,7 @@ impl PyTickHandle { ); last_idx = Some(idx); } - Err(err) => { - let msg = format!( - "Qubit(s) {:?} already in use in tick {}", - err.conflicting_qubits - .iter() - .map(std::string::ToString::to_string) - .collect::>(), - tick_idx - ); - return Err(PyErr::new::(msg)); - } + Err(err) => return Err(tick_gate_error_to_pyerr(err, Some(tick_idx))), } } drop(circuit); @@ -2956,17 +3493,7 @@ impl PyTickHandle { slf.borrow_mut(py).last_gate_idx = Some(idx); Ok(slf) } - Err(err) => { - let msg = format!( - "Qubit(s) {:?} already in use in tick {}", - err.conflicting_qubits - .iter() - .map(std::string::ToString::to_string) - .collect::>(), - tick_idx - ); - Err(PyErr::new::(msg)) - } + Err(err) => Err(tick_gate_error_to_pyerr(err, Some(tick_idx))), } } } @@ -3043,17 +3570,7 @@ impl PyTickHandle { slf.borrow_mut(py).last_gate_idx = Some(idx); Ok(slf) } - Err(err) => { - let msg = format!( - "Qubit(s) {:?} already in use in tick {}", - err.conflicting_qubits - .iter() - .map(std::string::ToString::to_string) - .collect::>(), - tick_idx - ); - Err(PyErr::new::(msg)) - } + Err(err) => Err(tick_gate_error_to_pyerr(err, Some(tick_idx))), } } else { drop(circuit); @@ -3083,35 +3600,91 @@ impl PyTickHandle { /// Measure qubits in the Z basis. /// - /// Returns a `TickMeasureHandle` that allows attaching metadata via `.meta()`. - /// This breaks the chain - only `.meta()` can be called on the result. - fn mz(slf: Py, py: Python<'_>, qubits: Vec) -> PyResult { - let (circuit, tick_idx, gate_idx) = { - let mut handle = slf.borrow_mut(py); - let gate_idx = handle.add_gate_get_idx(py, Gate::mz(&qubits))?; - (handle.circuit.clone_ref(py), handle.tick_idx, gate_idx) - }; - Ok(PyTickMeasureHandle { - circuit, - tick_idx, - gate_idx, - }) + /// Returns a list of `(tick, gate_idx, qubit)` measurement refs + /// for use in `detector()` and `observable()` annotations. + fn mz( + slf: Py, + py: Python<'_>, + qubits: Vec, + ) -> PyResult> { + let mut handle = slf.borrow_mut(py); + let mut gate = Gate::mz(&qubits); + // Assign MeasId values (SSA identity for each measurement) + { + let mut circuit = handle.circuit.borrow_mut(py); + let base = circuit.inner.num_measurements(); + for (i, _) in qubits.iter().enumerate() { + gate.meas_ids.push(pecos_core::MeasId(base + i)); + } + // Increment the measurement counter + circuit.inner.advance_meas_counter(qubits.len()); + } + let gate_idx = handle.add_gate_get_idx(py, gate)?; + let tick_idx = handle.tick_idx; + Ok(qubits.iter().map(|&q| (tick_idx, gate_idx, q)).collect()) + } + + /// Measure qubits with explicit MeasIds. + /// + /// Like ``mz()`` but assigns the given MeasIds instead of auto-assigning. + /// Used when MeasIds flow from an external source (e.g., Guppy result() IDs). + /// + /// The ``meas_ids`` list must have the same length as ``qubits``. + fn mz_with_ids( + slf: Py, + py: Python<'_>, + qubits: Vec, + meas_ids: Vec, + ) -> PyResult> { + if meas_ids.len() != qubits.len() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "meas_ids length {} != qubits length {}", + meas_ids.len(), + qubits.len() + ))); + } + let mut handle = slf.borrow_mut(py); + let mut gate = Gate::mz(&qubits); + for &mid in &meas_ids { + gate.meas_ids.push(pecos_core::MeasId(mid)); + } + { + let mut circuit = handle.circuit.borrow_mut(py); + // Advance counter to at least past the highest ID we're assigning + let max_id = meas_ids.iter().copied().max().unwrap_or(0); + let current = circuit.inner.num_measurements(); + if max_id >= current { + circuit.inner.advance_meas_counter(max_id + 1 - current); + } + } + let gate_idx = handle.add_gate_get_idx(py, gate)?; + let tick_idx = handle.tick_idx; + Ok(qubits.iter().map(|&q| (tick_idx, gate_idx, q)).collect()) } /// Measure and free qubits (destructive measurement). /// - /// Returns a `TickMeasureHandle` that allows attaching metadata via `.meta()`. - fn mz_free(slf: Py, py: Python<'_>, qubits: Vec) -> PyResult { - let (circuit, tick_idx, gate_idx) = { - let mut handle = slf.borrow_mut(py); - let gate_idx = handle.add_gate_get_idx(py, Gate::mz_free(&qubits))?; - (handle.circuit.clone_ref(py), handle.tick_idx, gate_idx) - }; - Ok(PyTickMeasureHandle { - circuit, - tick_idx, - gate_idx, - }) + /// Measure and free qubits (destructive measurement). + /// + /// Returns measurement refs for annotations. + fn mz_free( + slf: Py, + py: Python<'_>, + qubits: Vec, + ) -> PyResult> { + let mut handle = slf.borrow_mut(py); + let mut gate = Gate::mz_free(&qubits); + { + let mut circuit = handle.circuit.borrow_mut(py); + let base = circuit.inner.num_measurements(); + for (i, _) in qubits.iter().enumerate() { + gate.meas_ids.push(pecos_core::MeasId(base + i)); + } + circuit.inner.advance_meas_counter(qubits.len()); + } + let gate_idx = handle.add_gate_get_idx(py, gate)?; + let tick_idx = handle.tick_idx; + Ok(qubits.iter().map(|&q| (tick_idx, gate_idx, q)).collect()) } // --- Resource management --- diff --git a/python/pecos-rslib/src/decoder_bindings.rs b/python/pecos-rslib/src/decoder_bindings.rs index 8bee00356..bf9d27879 100644 --- a/python/pecos-rslib/src/decoder_bindings.rs +++ b/python/pecos-rslib/src/decoder_bindings.rs @@ -519,6 +519,50 @@ impl PyPyMatchingDecoder { .map_err(|e| PyErr::new::(e.to_string())) } + /// Decode a batch of syndromes at once. + /// + /// Much faster than calling `decode()` in a Python loop -- the entire batch + /// is processed in Rust with no per-shot Python overhead. + /// + /// # Arguments + /// + /// * `detection_events` - Flattened detection events array (`num_shots` * `num_detectors` bytes) + /// * `num_shots` - Number of shots in the batch + /// + /// # Returns + /// + /// List of observable predictions (one per shot), where each prediction + /// is a list of 0/1 values (one per observable). Use `observables_mask` + /// property on each element or just check index 0 for single-observable codes. + /// + /// # Example + /// + /// ```python + /// # detection_events is shape (num_shots, num_detectors), flattened + /// flat = detection_events.flatten().tolist() + /// predictions = decoder.decode_batch(flat, num_shots=len(detection_events)) + /// num_errors = sum(p[0] != t for p, t in zip(predictions, true_flips)) + /// ``` + fn decode_batch( + &mut self, + detection_events: Vec, + num_shots: usize, + ) -> PyResult>> { + use pecos_decoders::BatchConfig as RustBatchConfig; + + let num_detectors = self.inner.num_detectors(); + let config = RustBatchConfig { + bit_packed_input: false, + bit_packed_output: false, + return_weights: false, + }; + + self.inner + .decode_batch_with_config(&detection_events, num_shots, num_detectors, config) + .map(|result| result.predictions) + .map_err(|e| PyErr::new::(e.to_string())) + } + /// Number of detector nodes in the matching graph. #[getter] fn num_detectors(&self) -> usize { @@ -1452,6 +1496,8 @@ impl PyTesseractResult { #[pyclass(name = "TesseractDecoder", module = "pecos_rslib.decoders", unsendable)] pub struct PyTesseractDecoder { inner: RustTesseractDecoder, + dem_string: String, + config: RustTesseractConfig, } #[pymethods] @@ -1498,8 +1544,13 @@ impl PyTesseractDecoder { } config.verbose = verbose; - RustTesseractDecoder::new(dem, config) - .map(|inner| Self { inner }) + let dem_string = dem.to_string(); + RustTesseractDecoder::new(dem, config.clone()) + .map(|inner| Self { + inner, + dem_string, + config, + }) .map_err(|e| PyErr::new::(e.to_string())) } @@ -1553,6 +1604,82 @@ impl PyTesseractDecoder { self.decode(detections) } + /// Decode a batch of syndromes in parallel using multiple decoder instances. + /// + /// Creates worker decoders on background threads and distributes shots + /// across them. Much faster than sequential decoding for large batches. + /// + /// # Arguments + /// + /// * `syndromes` - List of dense syndrome vectors + /// * `num_workers` - Number of parallel workers (default: number of CPUs) + /// + /// # Returns + /// + /// List of `TesseractResult` in the same order as inputs. + #[pyo3(signature = (syndromes, num_workers=None))] + fn decode_batch( + &self, + syndromes: Vec>, + num_workers: Option, + ) -> PyResult> { + use rayon::prelude::*; + + let n_workers = num_workers.unwrap_or_else(rayon::current_num_threads); + + // Build a thread pool with the requested size + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(n_workers) + .build() + .map_err(|e| PyErr::new::(e.to_string()))?; + + let dem_str = &self.dem_string; + let config = &self.config; + + let results: Result, _> = pool.install(|| { + syndromes + .par_iter() + .map(|syndrome| { + // Each rayon task gets its own thread-local decoder + thread_local! { + static DECODER: std::cell::RefCell> = + const { std::cell::RefCell::new(None) }; + } + + DECODER.with(|cell| { + let mut decoder_ref = cell.borrow_mut(); + if decoder_ref.is_none() { + *decoder_ref = Some( + RustTesseractDecoder::new(dem_str, config.clone()) + .map_err(|e| e.to_string())?, + ); + } + let decoder = decoder_ref.as_mut().unwrap(); + + // Convert dense to sparse + let detections: Vec = syndrome + .iter() + .enumerate() + .filter_map(|(i, &val)| if val != 0 { Some(i as u64) } else { None }) + .collect(); + + let detections_arr = ndarray::Array1::from_vec(detections); + decoder + .decode_detections(&detections_arr.view()) + .map(|r| PyTesseractResult { + observables_mask: r.observables_mask, + cost: r.cost, + low_confidence: r.low_confidence, + }) + .map_err(|e| e.to_string()) + }) + }) + .collect() + }); + + results.map_err(PyErr::new::) + } + /// Number of detectors in the error model. #[getter] fn num_detectors(&self) -> usize { @@ -2036,6 +2163,292 @@ impl PyMinSumBpDecoder { } } +// ============================================================================= +// DEM-Aware Decoder (wraps check-matrix decoders for DEM-level decoding) +// ============================================================================= + +use pecos_decoder_core::DemCheckMatrix; + +/// Decoder type for the DEM-aware wrapper. +enum InnerDecoder { + BpOsd(RustBpOsdDecoder), + BpLsd(RustBpLsdDecoder), + UnionFind(RustUnionFindDecoder), + RelayBp(Box), + MinSumBp(Box), +} + +/// DEM-aware decoder that wraps a check-matrix decoder. +/// +/// Parses a DEM string, extracts the check matrix and observable matrix, +/// creates the inner decoder, and provides `decode_syndrome()` that returns +/// an `observables_mask` -- the same interface as `PyMatching` and Tesseract. +/// +/// # Example +/// +/// ```python +/// from pecos_rslib.decoders import DemAwareDecoder +/// +/// decoder = DemAwareDecoder.from_dem(dem_string, decoder_type="bp_osd") +/// result = decoder.decode_syndrome([0, 1, 1, 0]) +/// print(f"Observable prediction: {result.observables_mask}") +/// ``` +#[pyclass(name = "DemAwareDecoder", module = "pecos_rslib.decoders", unsendable)] +pub struct PyDemAwareDecoder { + inner: InnerDecoder, + dem_check_matrix: DemCheckMatrix, +} + +/// Result from a DEM-aware decoder. +#[pyclass( + name = "DemAwareResult", + module = "pecos_rslib.decoders", + skip_from_py_object +)] +#[derive(Clone)] +pub struct PyDemAwareResult { + /// Bitmask of predicted observable flips. + #[pyo3(get)] + pub observables_mask: u64, + /// Whether the BP decoder converged. + #[pyo3(get)] + pub converged: bool, + /// Number of BP iterations used. + #[pyo3(get)] + pub iterations: usize, +} + +#[pymethods] +impl PyDemAwareResult { + fn __repr__(&self) -> String { + format!( + "DemAwareResult(observables_mask={}, converged={}, iterations={})", + self.observables_mask, self.converged, self.iterations + ) + } +} + +#[pymethods] +impl PyDemAwareDecoder { + /// Create a DEM-aware decoder from a DEM string. + /// + /// # Arguments + /// + /// * `dem` - DEM string in Stim format + /// * `decoder_type` - One of "`bp_osd`", "`bp_lsd`", "`union_find`", "`relay_bp`", "`min_sum_bp`" + /// * `error_rate` - Override error rate for BP priors (default: use DEM probabilities) + /// * `max_iter` - Maximum BP iterations (default: 100) + /// + /// # Example + /// + /// ```python + /// decoder = DemAwareDecoder.from_dem(dem, decoder_type="bp_osd") + /// ``` + #[staticmethod] + #[pyo3(signature = (dem, decoder_type="bp_osd", error_rate=None, max_iter=100))] + fn from_dem( + dem: &str, + decoder_type: &str, + error_rate: Option, + max_iter: usize, + ) -> PyResult { + let dcm = DemCheckMatrix::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + + if dcm.num_mechanisms == 0 { + return Err(PyErr::new::( + "DEM contains no error mechanisms", + )); + } + + // Error priors: use per-mechanism probabilities from DEM, or uniform override + let priors: Vec = if let Some(p) = error_rate { + vec![p; dcm.num_mechanisms] + } else { + dcm.error_priors.clone() + }; + + // Build the check matrix in the two formats decoders need: + // SparseMatrix for LDPC decoders, Array2 view for Relay/MinSum. + let sparse_h = RustSparseMatrix::from_dense(&dcm.check_matrix.view()); + + let inner = match decoder_type { + "bp_osd" => { + let decoder = RustBpOsdDecoder::new( + &sparse_h, + None, // error_rate + Some(&priors), // error_channel + max_iter, + RustBpMethod::ProductSum, + RustBpSchedule::Parallel, + 1.0, // ms_scaling_factor + RustOsdMethod::Osd0, + 0, // osd_order + RustInputVectorType::Syndrome, + None, + None, + None, + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + InnerDecoder::BpOsd(decoder) + } + "bp_lsd" => { + let decoder = RustBpLsdDecoder::new( + &sparse_h, + None, // error_rate + Some(&priors), // error_channel + max_iter, + RustBpMethod::ProductSum, + RustBpSchedule::Parallel, + 1.0, // ms_scaling_factor + RustOsdMethod::Off, // lsd_method (LSD-0) + 0, // lsd_order + 0, // bits_per_step + RustInputVectorType::Syndrome, + None, + None, + None, + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + InnerDecoder::BpLsd(decoder) + } + "union_find" => { + let decoder = RustUnionFindDecoder::new(&sparse_h, RustUfMethod::Inversion) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + InnerDecoder::UnionFind(decoder) + } + "relay_bp" => { + use pecos_decoders::RelayBpBuilder as RustRelayBpBuilderT; + let h_view = dcm.check_matrix.view(); + let decoder = RustRelayBpBuilderT::new(&h_view) + .error_priors(&priors) + .max_iter(max_iter) + .build() + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + InnerDecoder::RelayBp(Box::new(decoder)) + } + "min_sum_bp" => { + use pecos_decoders::MinSumBpBuilder as RustMinSumBpBuilderT; + let h_view = dcm.check_matrix.view(); + let decoder = RustMinSumBpBuilderT::new(&h_view) + .error_priors(&priors) + .max_iter(max_iter) + .build() + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + InnerDecoder::MinSumBp(Box::new(decoder)) + } + _ => { + return Err(PyErr::new::(format!( + "Unknown decoder type: {decoder_type}. \ + Supported: bp_osd, bp_lsd, union_find, relay_bp, min_sum_bp" + ))); + } + }; + + Ok(Self { + inner, + dem_check_matrix: dcm, + }) + } + + /// Decode a dense syndrome vector. + /// + /// # Arguments + /// + /// * `syndrome` - Dense syndrome vector (0 or 1 for each detector) + /// + /// # Returns + /// + /// `DemAwareResult` with `observables_mask`, `converged`, and `iterations`. + fn decode_syndrome(&mut self, syndrome: Vec) -> PyResult { + let arr = Array1::from_vec(syndrome); + let (decoding, converged, iterations) = match &mut self.inner { + InnerDecoder::BpOsd(d) => { + let r = d.decode(&arr.view()).map_err(|e| { + PyErr::new::(e.to_string()) + })?; + (r.decoding.to_vec(), r.converged, r.iterations) + } + InnerDecoder::BpLsd(d) => { + let r = d.decode(&arr.view()).map_err(|e| { + PyErr::new::(e.to_string()) + })?; + (r.decoding.to_vec(), r.converged, r.iterations) + } + InnerDecoder::UnionFind(d) => { + let r = d.decode(&arr.view(), &[], 0).map_err(|e| { + PyErr::new::(e.to_string()) + })?; + (r.decoding.to_vec(), r.converged, r.iterations) + } + InnerDecoder::RelayBp(d) => { + let r = d.decode(&arr.view()).map_err(|e| { + PyErr::new::(e.to_string()) + })?; + (r.decoding.to_vec(), r.converged, r.iterations) + } + InnerDecoder::MinSumBp(d) => { + let r = d.decode(&arr.view()).map_err(|e| { + PyErr::new::(e.to_string()) + })?; + (r.decoding.to_vec(), r.converged, r.iterations) + } + }; + + let correction: Vec = decoding.iter().map(|&v| v & 1).collect(); + let observables_mask = self + .dem_check_matrix + .observables_mask_from_correction(&correction); + + Ok(PyDemAwareResult { + observables_mask, + converged, + iterations, + }) + } + + /// Number of detectors in the model. + #[getter] + fn num_detectors(&self) -> usize { + self.dem_check_matrix.num_detectors + } + + /// Number of observables in the model. + #[getter] + fn num_observables(&self) -> usize { + self.dem_check_matrix.num_observables + } + + /// Number of error mechanisms in the model. + #[getter] + fn num_mechanisms(&self) -> usize { + self.dem_check_matrix.num_mechanisms + } + + fn __repr__(&self) -> String { + let decoder_name = match &self.inner { + InnerDecoder::BpOsd(_) => "bp_osd", + InnerDecoder::BpLsd(_) => "bp_lsd", + InnerDecoder::UnionFind(_) => "union_find", + InnerDecoder::RelayBp(_) => "relay_bp", + InnerDecoder::MinSumBp(_) => "min_sum_bp", + }; + format!( + "DemAwareDecoder(type={}, detectors={}, mechanisms={}, observables={})", + decoder_name, + self.dem_check_matrix.num_detectors, + self.dem_check_matrix.num_mechanisms, + self.dem_check_matrix.num_observables, + ) + } +} + // ============================================================================= // Module Registration // ============================================================================= @@ -2075,6 +2488,10 @@ pub fn register_decoders_module(parent_module: &Bound<'_, PyModule>) -> PyResult decoders_module.add_class::()?; decoders_module.add_class::()?; + // DEM-aware decoder wrapper + decoders_module.add_class::()?; + decoders_module.add_class::()?; + // Add submodule to parent parent_module.add_submodule(&decoders_module)?; diff --git a/python/pecos-rslib/src/fault_tolerance_bindings.rs b/python/pecos-rslib/src/fault_tolerance_bindings.rs index ec593b879..4d2e459e6 100644 --- a/python/pecos-rslib/src/fault_tolerance_bindings.rs +++ b/python/pecos-rslib/src/fault_tolerance_bindings.rs @@ -48,13 +48,13 @@ use pecos_qec::fault_tolerance::dem_builder::{ ContributionRenderRecord as RustContributionRenderRecord, ContributionRenderStrategy as RustContributionRenderStrategy, ContributionRenderSummary as RustContributionRenderSummary, DemBuilder as RustDemBuilder, + DemSampler as RustNewDemSampler, DemSamplerBuilder as RustNewDemSamplerBuilder, DetectorErrorModel as RustDetectorErrorModel, DirectSourceFamily as RustDirectSourceFamily, EquivalenceResult as RustEquivalenceResult, FaultContribution as RustFaultContribution, - FaultSourceType as RustFaultSourceType, MeasurementNoiseModel as RustMeasurementNoiseModel, - MemBuilder as RustMemBuilder, ParsedDem as RustParsedDem, + FaultSourceType as RustFaultSourceType, NoiseConfig, ParsedDem as RustParsedDem, TwoDetectorDirectRenderPolicy as RustTwoDetectorDirectRenderPolicy, compare_dems_exact as rust_compare_dems_exact, - compare_dems_statistical as rust_compare_dems_statistical, record_offset_to_absolute_index, + compare_dems_statistical as rust_compare_dems_statistical, verify_dem_equivalence as rust_verify_dem_equivalence, }; use pecos_qec::fault_tolerance::influence_builder::InfluenceBuilder as RustInfluenceBuilder; @@ -67,16 +67,21 @@ use pecos_quantum::QubitId; use pyo3::Py; use pyo3::prelude::*; -/// Type alias for batch sampling results: (`detection_events_per_shot`, `observable_flips_per_shot`) -type BatchSampleResult = (Vec>, Vec>); +type PyDemMechanismTuple = (f64, Vec, Vec); +type PyDemFitResult = (Vec, Vec); -fn json_record_offset(value: &serde_json::Value) -> PyResult { - let raw = value - .as_i64() - .ok_or_else(|| pyo3::exceptions::PyValueError::new_err("Record offset must be integer"))?; - i32::try_from(raw).map_err(|_| { - pyo3::exceptions::PyValueError::new_err("Record offset must fit into signed 32-bit range") - }) +// Adapter for decoder factories that require `Send + Sync` trait objects. +// Decoder implementations own their state; Python access remains GIL-mediated. +struct SendWrapper(Box); +unsafe impl Send for SendWrapper {} +unsafe impl Sync for SendWrapper {} +impl pecos_decoders::ObservableDecoder for SendWrapper { + fn decode_to_observables( + &mut self, + syndrome: &[u8], + ) -> Result { + self.0.decode_to_observables(syndrome) + } } // ============================================================================= @@ -159,7 +164,7 @@ impl From<&DagSpacetimeLocation> for PyFaultLocation { /// A fault influence map built from a DAG circuit. /// -/// Maps fault locations to their effects on detectors and logical observables. +/// Maps fault locations to their effects on detectors and DEM outputs. /// Uses CSR (Compressed Sparse Row) layout for cache-efficient storage. /// /// This is functionally equivalent to a Detector Error Model (DEM) but stored @@ -172,7 +177,7 @@ impl From<&DagSpacetimeLocation> for PyFaultLocation { /// influence_map = analyzer.build_influence_map() /// /// # Query fault influence -/// has_syndrome, causes_logical = influence_map.classify_fault(loc_idx=0, pauli=1) +/// has_syndrome, flips_dem_output = influence_map.classify_fault(loc_idx=0, pauli=1) /// /// # Get detector indices flipped by this fault /// detector_indices = influence_map.get_detector_indices(loc_idx=0, pauli=1) @@ -196,13 +201,22 @@ impl PyDagFaultInfluenceMap { self.inner.detectors.len() } - /// Number of logical observables tracked. + /// Total number of outputs in the DEM `L` namespace. #[getter] - fn num_logicals(&self) -> usize { - self.inner - .influences - .max_logical_index() - .map_or(0, |i| i + 1) + fn num_dem_outputs(&self) -> usize { + self.inner.num_dem_outputs() + } + + /// Number of observable DEM outputs. + #[getter] + fn num_observables(&self) -> usize { + self.inner.num_observables() + } + + /// Number of tracked Paulis. + #[getter] + fn num_tracked_paulis(&self) -> usize { + self.inner.num_tracked_paulis() } /// Get all fault locations. @@ -235,11 +249,16 @@ impl PyDagFaultInfluenceMap { /// pauli: Pauli type (1=X, 2=Y, 3=Z). /// /// Returns: - /// Tuple (`has_syndrome`, `causes_logical_error`). + /// Tuple (`has_syndrome`, `flips_dem_output`). /// - `has_syndrome`: True if the fault flips at least one detector. - /// - `causes_logical_error`: True if the fault flips the logical observable. + /// - `flips_dem_output`: True if the fault flips at least one standard observable DEM output. fn classify_fault(&self, loc_idx: usize, pauli: u8) -> (bool, bool) { - self.inner.classify_fault(loc_idx, pauli) + ( + self.inner + .influences + .has_detector_flips(loc_idx, Pauli::from_u8(pauli)), + self.inner.has_observable_flips(loc_idx, pauli), + ) } /// Get detector indices flipped by a fault. @@ -254,16 +273,36 @@ impl PyDagFaultInfluenceMap { self.inner.get_detector_indices(loc_idx, pauli).to_vec() } - /// Get logical indices flipped by a fault. + /// Get standard DEM `L` observable indices flipped by a fault. + fn get_dem_output_indices(&self, loc_idx: usize, pauli: u8) -> Vec { + self.inner.get_observable_indices(loc_idx, pauli) + } + + /// Get raw internal non-detector influence indices flipped by a fault. + /// + /// These are implementation indices used to propagate both observables and + /// tracked Paulis. Prefer `get_dem_output_indices`, + /// `get_observable_indices`, or `get_tracked_pauli_indices` for public DEM + /// semantics. + fn get_internal_dem_output_indices(&self, loc_idx: usize, pauli: u8) -> Vec { + self.inner.get_dem_output_indices(loc_idx, pauli).to_vec() + } + + /// Get tracked-Pauli indices flipped by a fault. /// /// Args: /// `loc_idx`: Location index. /// pauli: Pauli type (1=X, 2=Y, 3=Z). /// /// Returns: - /// List of logical indices that are flipped by this fault. - fn get_logical_indices(&self, loc_idx: usize, pauli: u8) -> Vec { - self.inner.get_logical_indices(loc_idx, pauli).to_vec() + /// List of tracked-Pauli indices that are flipped by this fault. + fn get_tracked_pauli_indices(&self, loc_idx: usize, pauli: u8) -> Vec { + self.inner.get_tracked_pauli_indices(loc_idx, pauli) + } + + /// Get observable indices flipped by a fault. + fn get_observable_indices(&self, loc_idx: usize, pauli: u8) -> Vec { + self.inner.get_observable_indices(loc_idx, pauli) } /// Check if a fault at the given location flips any detector. @@ -280,18 +319,26 @@ impl PyDagFaultInfluenceMap { .has_detector_flips(loc_idx, Pauli::from_u8(pauli)) } - /// Check if a fault at the given location flips a logical observable. + /// Check if a fault at the given location flips any standard DEM output. + fn has_dem_output_flips(&self, loc_idx: usize, pauli: u8) -> bool { + self.inner.has_observable_flips(loc_idx, pauli) + } + + /// Check if a fault at the given location flips any observable. + fn has_observable_flips(&self, loc_idx: usize, pauli: u8) -> bool { + self.inner.has_observable_flips(loc_idx, pauli) + } + + /// Check if a fault at the given location flips any tracked Pauli. /// /// Args: /// `loc_idx`: Location index. /// pauli: Pauli type (1=X, 2=Y, 3=Z). /// /// Returns: - /// True if the fault flips the logical observable. - fn has_logical_flips(&self, loc_idx: usize, pauli: u8) -> bool { - self.inner - .influences - .has_logical_flips(loc_idx, Pauli::from_u8(pauli)) + /// True if the fault flips at least one tracked Pauli. + fn has_tracked_pauli_flips(&self, loc_idx: usize, pauli: u8) -> bool { + self.inner.has_tracked_pauli_flips(loc_idx, pauli) } /// Get memory statistics for this influence map. @@ -303,7 +350,7 @@ impl PyDagFaultInfluenceMap { let dict = pyo3::types::PyDict::new(py); dict.set_item("num_locations", stats.num_locations)?; dict.set_item("total_detector_entries", stats.total_detector_entries)?; - dict.set_item("total_logical_entries", stats.total_logical_entries)?; + dict.set_item("total_dem_output_entries", stats.total_dem_output_entries)?; dict.set_item("offset_bytes", stats.offset_bytes)?; dict.set_item("data_bytes", stats.data_bytes)?; dict.set_item("total_bytes", stats.total_bytes)?; @@ -314,48 +361,57 @@ impl PyDagFaultInfluenceMap { /// /// Returns: /// Dictionary containing all CSR arrays: - /// - `num_locations`, `num_detectors`, `num_logicals` + /// - `num_locations`, `num_detectors`, `num_dem_outputs` + /// - `num_internal_dem_outputs` for the raw CSR bit-plane width /// - `detector_offsets_x`, `detector_data_x` /// - `detector_offsets_y`, `detector_data_y` /// - `detector_offsets_z`, `detector_data_z` - /// - `logical_offsets_x`, `logical_data_x` - /// - `logical_offsets_y`, `logical_data_y` - /// - `logical_offsets_z`, `logical_data_z` + /// - `dem_output_offsets_x`, `dem_output_data_x` + /// - `dem_output_offsets_y`, `dem_output_data_y` + /// - `dem_output_offsets_z`, `dem_output_data_z` fn export_csr(&self, py: Python<'_>) -> PyResult> { + let num_internal_dem_outputs = self + .inner + .influences + .max_dem_output_index() + .map_or(0, |idx| idx + 1); let ( num_locations, num_detectors, - num_logicals, + num_dem_outputs, det_off_x, det_data_x, det_off_y, det_data_y, det_off_z, det_data_z, - log_off_x, - log_data_x, - log_off_y, - log_data_y, - log_off_z, - log_data_z, + dem_output_offsets_x, + dem_output_data_x, + dem_output_offsets_y, + dem_output_data_y, + dem_output_offsets_z, + dem_output_data_z, ) = self.inner.export_csr(); let dict = pyo3::types::PyDict::new(py); dict.set_item("num_locations", num_locations)?; dict.set_item("num_detectors", num_detectors)?; - dict.set_item("num_logicals", num_logicals)?; + dict.set_item("num_dem_outputs", num_dem_outputs)?; + dict.set_item("num_internal_dem_outputs", num_internal_dem_outputs)?; + dict.set_item("num_observables", self.num_observables())?; + dict.set_item("num_tracked_paulis", self.num_tracked_paulis())?; dict.set_item("detector_offsets_x", det_off_x)?; dict.set_item("detector_data_x", det_data_x)?; dict.set_item("detector_offsets_y", det_off_y)?; dict.set_item("detector_data_y", det_data_y)?; dict.set_item("detector_offsets_z", det_off_z)?; dict.set_item("detector_data_z", det_data_z)?; - dict.set_item("logical_offsets_x", log_off_x)?; - dict.set_item("logical_data_x", log_data_x)?; - dict.set_item("logical_offsets_y", log_off_y)?; - dict.set_item("logical_data_y", log_data_y)?; - dict.set_item("logical_offsets_z", log_off_z)?; - dict.set_item("logical_data_z", log_data_z)?; + dict.set_item("dem_output_offsets_x", &dem_output_offsets_x)?; + dict.set_item("dem_output_data_x", &dem_output_data_x)?; + dict.set_item("dem_output_offsets_y", &dem_output_offsets_y)?; + dict.set_item("dem_output_data_y", &dem_output_data_y)?; + dict.set_item("dem_output_offsets_z", &dem_output_offsets_z)?; + dict.set_item("dem_output_data_z", &dem_output_data_z)?; Ok(dict.unbind()) } @@ -374,10 +430,10 @@ impl PyDagFaultInfluenceMap { fn __repr__(&self) -> String { format!( - "DagFaultInfluenceMap(locations={}, detectors={}, logicals={})", + "DagFaultInfluenceMap(locations={}, detectors={}, tracked_paulis={})", self.num_locations(), self.num_detectors(), - self.num_logicals() + self.num_tracked_paulis() ) } @@ -498,16 +554,18 @@ impl PyDagFaultAnalyzer { /// dag = DagCircuit() /// # ... build circuit ... /// -/// # Build influence map with logical operator tracking +/// # Build influence map with tracked Paulis /// builder = InfluenceBuilder(dag) -/// builder.with_logical_z([0, 1, 2]) # Top row qubits for d=3 surface code +/// builder.with_tracked_z([0, 1, 2]) # Track a Z string on these qubits /// influence_map = builder.build() /// ``` #[pyclass(name = "InfluenceBuilder", module = "pecos_rslib.qec")] pub struct PyInfluenceBuilder { dag: DagCircuit, - logical_x_qubits: Vec, - logical_z_qubits: Vec, + tracked_x_qubits: Vec, + tracked_z_qubits: Vec, + tracked_paulis: Vec, + use_circuit_tracked_paulis: bool, } #[pymethods] @@ -520,38 +578,86 @@ impl PyInfluenceBuilder { fn new(dag: &crate::dag_circuit_bindings::PyDagCircuit) -> Self { Self { dag: dag.inner.clone(), - logical_x_qubits: Vec::new(), - logical_z_qubits: Vec::new(), + tracked_x_qubits: Vec::new(), + tracked_z_qubits: Vec::new(), + tracked_paulis: Vec::new(), + use_circuit_tracked_paulis: false, } } - /// Add a logical X operator to track. + /// Add an X-string tracked Pauli. + /// + /// The tracked Pauli is X on all specified qubits and is sensitive to Z errors. + /// + /// Args: + /// qubits: List of qubit indices for the tracked X Pauli. + /// + /// Returns: + /// Self for method chaining. + fn with_tracked_x(mut slf: PyRefMut<'_, Self>, qubits: Vec) -> PyRefMut<'_, Self> { + slf.tracked_x_qubits = qubits; + slf + } + + /// Add a Z-string tracked Pauli. /// - /// The logical X is defined as X on all specified qubits. - /// This logical is sensitive to Z errors. + /// The tracked Pauli is Z on all specified qubits and is sensitive to X errors. /// /// Args: - /// qubits: List of qubit indices for the logical X operator. + /// qubits: List of qubit indices for the tracked Z Pauli. /// /// Returns: /// Self for method chaining. - fn with_logical_x(mut slf: PyRefMut<'_, Self>, qubits: Vec) -> PyRefMut<'_, Self> { - slf.logical_x_qubits = qubits; + fn with_tracked_z(mut slf: PyRefMut<'_, Self>, qubits: Vec) -> PyRefMut<'_, Self> { + slf.tracked_z_qubits = qubits; slf } - /// Add a logical Z operator to track. + /// Add a tracked Pauli. /// - /// The logical Z is defined as Z on all specified qubits. - /// This logical is sensitive to X errors. + /// Each entry is a `(qubit, pauli)` tuple where pauli is "X", "Y", or "Z". /// /// Args: - /// qubits: List of qubit indices for the logical Z operator. + /// entries: List of (`qubit_index`, `pauli_str`) tuples. + /// + /// Returns: + /// Self for method chaining. + fn with_tracked_pauli( + mut slf: PyRefMut<'_, Self>, + entries: Vec<(usize, String)>, + ) -> PyResult> { + let paulis: Vec<(pecos_core::Pauli, pecos_core::QubitId)> = entries + .iter() + .map(|(qubit, p)| { + let pauli = match p.to_uppercase().as_str() { + "X" => Ok(pecos_core::Pauli::X), + "Y" => Ok(pecos_core::Pauli::Y), + "Z" => Ok(pecos_core::Pauli::Z), + _ => Err(pyo3::exceptions::PyValueError::new_err(format!( + "Invalid Pauli type: {p}. Expected 'X', 'Y', or 'Z'." + ))), + }?; + Ok((pauli, pecos_core::QubitId::from(*qubit))) + }) + .collect::>()?; + slf.tracked_paulis + .push(pecos_core::PauliString::with_phase_and_paulis( + pecos_core::QuarterPhase::PlusOne, + paulis, + )); + Ok(slf) + } + + /// Use annotations from the circuit (observables and tracked Paulis). + /// + /// Extracts observable and `tracked_pauli()` annotations from the + /// circuit. Tracked Paulis are propagated with positional awareness + /// (only faults before each annotation's position affect it). /// /// Returns: /// Self for method chaining. - fn with_logical_z(mut slf: PyRefMut<'_, Self>, qubits: Vec) -> PyRefMut<'_, Self> { - slf.logical_z_qubits = qubits; + fn with_circuit_annotations(mut slf: PyRefMut<'_, Self>) -> PyRefMut<'_, Self> { + slf.use_circuit_tracked_paulis = true; slf } @@ -563,11 +669,23 @@ impl PyInfluenceBuilder { /// 3. Backward propagation to build the influence map /// /// Returns: - /// `DagFaultInfluenceMap` with proper detector definitions and logical tracking. + /// `DagFaultInfluenceMap` with proper detector definitions and tracked Paulis. fn build(&self) -> PyDagFaultInfluenceMap { - let builder = RustInfluenceBuilder::new(&self.dag) - .with_logical_x(self.logical_x_qubits.clone()) - .with_logical_z(self.logical_z_qubits.clone()); + let mut builder = RustInfluenceBuilder::new(&self.dag); + + if !self.tracked_x_qubits.is_empty() { + builder = builder.with_x(&self.tracked_x_qubits); + } + if !self.tracked_z_qubits.is_empty() { + builder = builder.with_z(&self.tracked_z_qubits); + } + + if self.use_circuit_tracked_paulis { + builder = builder.with_circuit_annotations(&self.dag); + } + for pauli in &self.tracked_paulis { + builder = builder.with_tracked_pauli(pauli.clone()); + } let inner = builder.build(); PyDagFaultInfluenceMap { inner } @@ -575,8 +693,11 @@ impl PyInfluenceBuilder { fn __repr__(&self) -> String { format!( - "InfluenceBuilder(logical_x={:?}, logical_z={:?})", - self.logical_x_qubits, self.logical_z_qubits + "InfluenceBuilder(tracked_x={:?}, tracked_z={:?}, tracked_paulis={}, circuit_annotations={})", + self.tracked_x_qubits, + self.tracked_z_qubits, + self.tracked_paulis.len(), + self.use_circuit_tracked_paulis, ) } } @@ -585,11 +706,11 @@ impl PyInfluenceBuilder { // Detector Error Model // ============================================================================= -/// A Detector Error Model (DEM) in Stim-compatible format. +/// A Detector Error Model (DEM) in standard DEM text format. /// /// This represents the error model of a quantum circuit, mapping error -/// mechanisms to their probabilities. It can be converted to Stim format -/// for use with Stim-based decoders. +/// mechanisms to their probabilities. It can be exported as DEM text for use +/// with compatible decoders. /// /// # Example /// @@ -610,13 +731,45 @@ pub struct PyDetectorErrorModel { inner: RustDetectorErrorModel, } +fn split_dem_outputs_for_dem( + dem_outputs: &[u32], + dem: &RustDetectorErrorModel, +) -> (Vec, Vec) { + if dem + .dem_outputs() + .iter() + .all(|output| output.kind.is_none() && output.records.is_empty() && output.pauli.is_none()) + { + return (dem_outputs.to_vec(), Vec::new()); + } + + let mut observables = Vec::new(); + let mut tracked_paulis = Vec::new(); + for &output_id in dem_outputs { + if let Some(output) = dem.dem_outputs().get(output_id as usize) { + if output.is_observable() { + observables.push(output_id); + } + if output.is_tracked_pauli() { + tracked_paulis.push(output_id); + } + } + } + (observables, tracked_paulis) +} + fn contribution_summary_to_pydict( py: Python<'_>, summary: RustContributionEffectSummary, + dem: &RustDetectorErrorModel, ) -> PyResult> { let dict = pyo3::types::PyDict::new(py); dict.set_item("detectors", summary.effect.detectors.to_vec())?; - dict.set_item("logicals", summary.effect.logicals.to_vec())?; + let dem_outputs = summary.effect.dem_outputs.to_vec(); + let (observables, tracked_paulis) = split_dem_outputs_for_dem(&dem_outputs, dem); + dict.set_item("dem_outputs", &dem_outputs)?; + dict.set_item("observables", observables)?; + dict.set_item("tracked_paulis", tracked_paulis)?; dict.set_item("num_contributions", summary.num_contributions)?; dict.set_item("total_probability", summary.total_probability)?; dict.set_item("direct_count", summary.direct_count)?; @@ -633,10 +786,15 @@ fn contribution_summary_to_pydict( fn contribution_render_summary_to_pydict( py: Python<'_>, summary: RustContributionRenderSummary, + dem: &RustDetectorErrorModel, ) -> PyResult> { let dict = pyo3::types::PyDict::new(py); dict.set_item("detectors", summary.effect.detectors.to_vec())?; - dict.set_item("logicals", summary.effect.logicals.to_vec())?; + let dem_outputs = summary.effect.dem_outputs.to_vec(); + let (observables, tracked_paulis) = split_dem_outputs_for_dem(&dem_outputs, dem); + dict.set_item("dem_outputs", &dem_outputs)?; + dict.set_item("observables", observables)?; + dict.set_item("tracked_paulis", tracked_paulis)?; dict.set_item("rendered_targets", summary.rendered_targets)?; dict.set_item("num_contributions", summary.num_contributions)?; dict.set_item("total_probability", summary.total_probability)?; @@ -660,8 +818,9 @@ fn contribution_render_summary_to_pydict( fn contribution_render_record_to_pydict( py: Python<'_>, record: RustContributionRenderRecord, + dem: &RustDetectorErrorModel, ) -> PyResult> { - let dict = contribution_record_to_pydict(py, record.contribution)?; + let dict = contribution_record_to_pydict(py, record.contribution, dem)?; let render_strategy = match record.render_strategy { RustContributionRenderStrategy::SourceComponents => "SourceComponents", RustContributionRenderStrategy::RecordedComponents => "RecordedComponents", @@ -696,6 +855,7 @@ fn parse_two_detector_direct_render_policy( fn contribution_record_to_pydict( py: Python<'_>, contribution: RustFaultContribution, + dem: &RustDetectorErrorModel, ) -> PyResult> { fn pauli_label(pauli: Pauli) -> &'static str { match pauli { @@ -708,7 +868,11 @@ fn contribution_record_to_pydict( let dict = pyo3::types::PyDict::new(py); dict.set_item("detectors", contribution.effect.detectors.to_vec())?; - dict.set_item("logicals", contribution.effect.logicals.to_vec())?; + let dem_outputs = contribution.effect.dem_outputs.to_vec(); + let (observables, tracked_paulis) = split_dem_outputs_for_dem(&dem_outputs, dem); + dict.set_item("dem_outputs", &dem_outputs)?; + dict.set_item("observables", observables)?; + dict.set_item("tracked_paulis", tracked_paulis)?; dict.set_item("probability", contribution.probability)?; dict.set_item("location_indices", contribution.location_indices.to_vec())?; dict.set_item( @@ -745,31 +909,31 @@ fn contribution_record_to_pydict( dict.set_item("source_type", "Direct")?; if let Some((first, second)) = contribution.direct_component_effects { dict.set_item("component_1_detectors", first.detectors.to_vec())?; - dict.set_item("component_1_logicals", first.logicals.to_vec())?; + dict.set_item("component_1_dem_outputs", first.dem_outputs.to_vec())?; dict.set_item("component_2_detectors", second.detectors.to_vec())?; - dict.set_item("component_2_logicals", second.logicals.to_vec())?; + dict.set_item("component_2_dem_outputs", second.dem_outputs.to_vec())?; } } RustFaultSourceType::DirectOneSidedComponent => { dict.set_item("source_type", "DirectOneSidedComponent")?; if let Some((first, second)) = contribution.direct_component_effects { dict.set_item("component_1_detectors", first.detectors.to_vec())?; - dict.set_item("component_1_logicals", first.logicals.to_vec())?; + dict.set_item("component_1_dem_outputs", first.dem_outputs.to_vec())?; dict.set_item("component_2_detectors", second.detectors.to_vec())?; - dict.set_item("component_2_logicals", second.logicals.to_vec())?; + dict.set_item("component_2_dem_outputs", second.dem_outputs.to_vec())?; } } RustFaultSourceType::YDecomposed { x_detectors, - x_logicals, + x_dem_outputs, z_detectors, - z_logicals, + z_dem_outputs, } => { dict.set_item("source_type", "YDecomposed")?; dict.set_item("x_detectors", x_detectors.to_vec())?; - dict.set_item("x_logicals", x_logicals.to_vec())?; + dict.set_item("x_dem_outputs", x_dem_outputs.to_vec())?; dict.set_item("z_detectors", z_detectors.to_vec())?; - dict.set_item("z_logicals", z_logicals.to_vec())?; + dict.set_item("z_dem_outputs", z_dem_outputs.to_vec())?; } } @@ -778,23 +942,88 @@ fn contribution_record_to_pydict( #[pymethods] impl PyDetectorErrorModel { + /// Build a DetectorErrorModel directly from a circuit and noise. + /// + /// Accepts both `TickCircuit` and `DagCircuit`. Reads detector/tracked-Pauli + /// definitions from circuit metadata. + /// + /// Example: + /// >>> dem = DetectorErrorModel.from_circuit(tc, p2=0.01) + /// >>> 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))] + fn from_circuit( + circuit: &pyo3::Bound<'_, pyo3::PyAny>, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + ) -> PyResult { + use pecos_qec::fault_tolerance::dem_builder::DemBuilder; + + if let Ok(dag) = + circuit.extract::>() + { + Ok(Self { + inner: DemBuilder::from_circuit(&dag.inner, p1, p2, p_meas, p_prep), + }) + } else if let Ok(tc) = + circuit.extract::>() + { + Ok(Self { + inner: DemBuilder::from_tick_circuit(&tc.inner, p1, p2, p_meas, p_prep), + }) + } else { + Err(pyo3::exceptions::PyTypeError::new_err( + "from_circuit() expects a DagCircuit or TickCircuit", + )) + } + } + + /// Build a DetectorErrorModel from PECOS DEM metadata JSON. + /// + /// This imports observable and tracked-Pauli metadata only; mechanism + /// errors must be provided through DEM text or built from a circuit. + /// + /// Raises: + /// `ValueError`: If the metadata JSON is malformed or uses unsupported fields. + #[staticmethod] + fn from_pecos_metadata_json(json: &str) -> PyResult { + let inner = RustDetectorErrorModel::new() + .with_pecos_metadata_json(json) + .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string()))?; + Ok(Self { inner }) + } + /// Number of detectors in the model. #[getter] fn num_detectors(&self) -> usize { self.inner.num_detectors() } - /// Number of logical observables in the model. + /// Number of observables in the model. #[getter] fn num_observables(&self) -> usize { self.inner.num_observables() } + /// Total number of outputs in the DEM `L` namespace. + #[getter] + fn num_dem_outputs(&self) -> usize { + self.inner.num_dem_outputs() + } + + /// Number of tracked Paulis in the model. + #[getter] + fn num_tracked_paulis(&self) -> usize { + self.inner.num_tracked_paulis() + } + /// Convert the DEM to a string in standard DEM format. /// /// Each error mechanism is output with its total probability, with no - /// splitting into decomposed forms. This matches Stim's - /// `detector_error_model(decompose_errors=False)` output. + /// splitting into decomposed forms. /// /// Returns: /// A string in DEM format with one entry per mechanism. @@ -809,8 +1038,6 @@ impl PyDetectorErrorModel { /// including L0 cancellation forms where available. Hyperedge errors /// (affecting 3+ detectors) are decomposed into graphlike components. /// - /// This matches Stim's `detector_error_model(decompose_errors=True)` output. - /// /// Returns: /// A string in DEM format with decomposed representations. fn to_string_decomposed(&self) -> String { @@ -855,12 +1082,20 @@ impl PyDetectorErrorModel { /// Returns debug info about all unique contribution effects. /// - /// Shows each unique detector/logical pattern and how many contributions + /// Shows each unique detector/DEM-output pattern and how many contributions /// target it with their total probability. fn all_contribution_effects(&self) -> String { self.inner.all_contribution_effects() } + /// Build a `DemSampler` directly from this DEM — no string round-trip. + fn to_sampler(&self) -> PyResult { + use pecos_qec::fault_tolerance::dem_builder::DemSampler; + + let inner = DemSampler::from_detector_error_model(&self.inner); + Ok(PyDemSampler { inner }) + } + /// Returns structured summaries for all unique contribution effects. fn contribution_effect_summaries( &self, @@ -869,7 +1104,7 @@ impl PyDetectorErrorModel { self.inner .contribution_effect_summaries() .into_iter() - .map(|summary| contribution_summary_to_pydict(py, summary)) + .map(|summary| contribution_summary_to_pydict(py, summary, &self.inner)) .collect() } @@ -881,7 +1116,7 @@ impl PyDetectorErrorModel { self.inner .contribution_render_summaries() .into_iter() - .map(|summary| contribution_render_summary_to_pydict(py, summary)) + .map(|summary| contribution_render_summary_to_pydict(py, summary, &self.inner)) .collect() } @@ -896,7 +1131,7 @@ impl PyDetectorErrorModel { self.inner .contribution_render_summaries_with_two_detector_direct_policy(policy) .into_iter() - .map(|summary| contribution_render_summary_to_pydict(py, summary)) + .map(|summary| contribution_render_summary_to_pydict(py, summary, &self.inner)) .collect() } @@ -908,7 +1143,7 @@ impl PyDetectorErrorModel { self.inner .contribution_render_records() .into_iter() - .map(|record| contribution_render_record_to_pydict(py, record)) + .map(|record| contribution_render_record_to_pydict(py, record, &self.inner)) .collect() } @@ -923,29 +1158,31 @@ impl PyDetectorErrorModel { self.inner .contribution_render_records_with_two_detector_direct_policy(policy) .into_iter() - .map(|record| contribution_render_record_to_pydict(py, record)) + .map(|record| contribution_render_record_to_pydict(py, record, &self.inner)) .collect() } - /// Returns source-tracked contributions for a full detector/logical effect. + /// Returns source-tracked contributions for a full detector/DEM-output effect. fn contributions_for_effect( &self, py: Python<'_>, detectors: Vec, - logicals: Vec, + dem_outputs: Vec, ) -> PyResult>> { self.inner - .contributions_for_effect(&detectors, &logicals) + .contributions_for_effect(&detectors, &dem_outputs) .into_iter() - .map(|contribution| contribution_record_to_pydict(py, contribution)) + .map(|contribution| contribution_record_to_pydict(py, contribution, &self.inner)) .collect() } fn __repr__(&self) -> String { format!( - "DetectorErrorModel(detectors={}, observables={}, contributions={})", + "DetectorErrorModel(detectors={}, dem_outputs={}, observables={}, tracked_paulis={}, contributions={})", self.num_detectors(), + self.num_dem_outputs(), self.num_observables(), + self.num_tracked_paulis(), self.num_contributions() ) } @@ -959,12 +1196,17 @@ impl PyDetectorErrorModel { // DEM Builder // ============================================================================= -/// Builder for Detector Error Models (DEMs). +/// Advanced builder for Detector Error Models (DEMs). /// -/// Constructs a DEM from a fault influence map and detector/observable metadata. -/// Uses the per-qubit fault model for accurate depolarizing noise analysis. +/// For most use cases, prefer `DetectorErrorModel.from_circuit()` or +/// `DemSampler.from_circuit()` which handle everything automatically. /// -/// # Example +/// Use `DemBuilder` directly when you need: +/// - A custom fault influence map +/// - Non-standard noise configuration +/// - Manual detector and observable definitions +/// +/// # Example (advanced) /// /// ```python /// from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder @@ -990,15 +1232,10 @@ impl PyDetectorErrorModel { #[pyclass(name = "DemBuilder", module = "pecos_rslib.qec")] pub struct PyDemBuilder { influence_map: RustDagFaultInfluenceMap, - p1: f64, - p2: f64, - p_meas: f64, - p_init: f64, + noise: NoiseConfig, detectors_json: Option, observables_json: Option, num_measurements: Option, - /// Measurement order: list of qubits in `TickCircuit` measurement execution order. - /// This allows proper mapping between record offsets and influence map indices. measurement_order: Option>, } @@ -1012,10 +1249,7 @@ impl PyDemBuilder { fn new(influence_map: &PyDagFaultInfluenceMap) -> Self { Self { influence_map: influence_map.inner.clone(), - p1: 0.01, - p2: 0.01, - p_meas: 0.01, - p_init: 0.01, + noise: NoiseConfig::default(), detectors_json: None, observables_json: None, num_measurements: None, @@ -1029,21 +1263,35 @@ impl PyDemBuilder { /// p1: Single-qubit depolarizing error rate. /// p2: Two-qubit depolarizing error rate. /// `p_meas`: Measurement error rate. - /// `p_init`: Initialization (prep) error rate. + /// `p_prep`: Initialization (prep) error rate. + /// `p_idle`: Optional idle noise rate per time unit. + /// t1: Optional T1 relaxation time. + /// t2: Optional T2 dephasing time. /// /// Returns: /// Self for method chaining. + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None))] + #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, p1: f64, p2: f64, p_meas: f64, - p_init: f64, + p_prep: f64, + p_idle: Option, + t1: Option, + t2: Option, + idle_rz: Option, ) -> PyRefMut<'_, Self> { - slf.p1 = p1; - slf.p2 = p2; - slf.p_meas = p_meas; - slf.p_init = p_init; + 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 } @@ -1063,13 +1311,8 @@ impl PyDemBuilder { /// Set the observable definitions from JSON. /// - /// Args: - /// json: JSON string with observable definitions. - /// Format: [{"id": 0, "records": [-1, -3, -5]}, ...] - /// Public surface descriptors using "`observable_id`" are also accepted. - /// - /// Returns: - /// Self for method chaining. + /// Tracked Paulis are carried by the influence map; this helper is for + /// observable metadata. fn with_observables_json(mut slf: PyRefMut<'_, Self>, json: String) -> PyRefMut<'_, Self> { slf.observables_json = Some(json); slf @@ -1116,12 +1359,8 @@ impl PyDemBuilder { /// Raises: /// `ValueError`: If the detector or observable JSON is malformed. fn build(&self) -> PyResult { - let mut builder = RustDemBuilder::new(&self.influence_map).with_noise( - self.p1, - self.p2, - self.p_meas, - self.p_init, - ); + let mut builder = + RustDemBuilder::new(&self.influence_map).with_noise_config(self.noise.clone()); if let Some(num) = self.num_measurements { builder = builder.with_num_measurements(num); @@ -1154,8 +1393,8 @@ impl PyDemBuilder { fn __repr__(&self) -> String { format!( - "DemBuilder(p1={}, p2={}, p_meas={}, p_init={})", - self.p1, self.p2, self.p_meas, self.p_init + "DemBuilder(p1={}, p2={}, p_meas={}, p_prep={}, p_idle={:?})", + self.noise.p1, self.noise.p2, self.noise.p_meas, self.noise.p_prep, self.noise.p_idle ) } } @@ -1164,984 +1403,1950 @@ impl PyDemBuilder { // Helper Functions // ============================================================================= -/// Parse detector records from JSON string. +/// `UnionFind` decoder that passes LLRs (from DEM error priors) for weighted decoding. /// -/// Extracts the "records" arrays from detector definitions. -fn parse_detector_records(detectors_json: &str) -> PyResult>> { - if detectors_json.is_empty() { - return Ok(Vec::new()); - } - - let detectors: Vec = serde_json::from_str(detectors_json) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("Invalid JSON: {e}")))?; - - let mut detector_records = Vec::with_capacity(detectors.len()); +/// The generic `CheckMatrixObservableDecoder` calls `Decoder::decode` which +/// passes empty LLRs. This wrapper stores the LLRs and passes them through +/// to the C++ UF decoder each shot, giving it edge-weight information. +struct WeightedUfObservableDecoder { + decoder: pecos_decoders::UnionFindDecoder, + dcm: pecos_decoder_core::dem::DemCheckMatrix, + llrs: Vec, +} - for det in &detectors { - let records = det - .get("records") - .and_then(|r| r.as_array()) - .ok_or_else(|| { - pyo3::exceptions::PyValueError::new_err("Detector missing 'records' array") - })?; +impl pecos_decoders::ObservableDecoder for WeightedUfObservableDecoder { + fn decode_to_observables( + &mut self, + syndrome: &[u8], + ) -> 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 + // add zero bits per step, looping forever. + let result = self + .decoder + .decode(&arr.view(), &self.llrs, 1) + .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; + Ok(self + .dcm + .observables_mask_from_correction(result.decoding.as_slice().unwrap_or(&[]))) + } +} - let offsets: Vec = records - .iter() - .map(json_record_offset) - .collect::>>()?; +/// Wrapper that relabels syndromes before passing to an inner decoder. +/// +/// Used for Fusion Blossom parallel where detector IDs need to be +/// round-contiguous for partitioning. +struct RelabeledObservableDecoder { + decoder: pecos_decoders::FusionBlossomDecoder, + old_to_new: Vec, +} - detector_records.push(offsets); +impl pecos_decoders::ObservableDecoder for RelabeledObservableDecoder { + fn decode_to_observables( + &mut self, + syndrome: &[u8], + ) -> Result { + // Relabel syndrome into the expanded vertex space (detectors + virtual + gap) + let expected = self.decoder.num_nodes(); + let mut relabeled = vec![0u8; expected]; + for (old_id, &val) in syndrome.iter().enumerate() { + if old_id < self.old_to_new.len() { + let new_id = self.old_to_new[old_id]; + if new_id < expected { + relabeled[new_id] = val; + } + } + } + let arr = ndarray::Array1::from_vec(relabeled); + let result = self + .decoder + .decode(&arr.view()) + .map_err(|e| pecos_decoder_core::DecoderError::DecodingFailed(e.to_string()))?; + let mut mask = 0u64; + for (i, &v) in result.observable.iter().enumerate() { + if v != 0 { + mask |= 1 << i; + } + } + Ok(mask) } +} - Ok(detector_records) +/// Convert a `DemMatchingGraph` to a DEM string for inner decoder construction. +fn subgraph_to_dem_string(graph: &pecos_decoder_core::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") } -/// Parse observable records from JSON string. +/// Create an `ObservableDecoder` from a DEM string and decoder type name. /// -/// Extracts the "records" arrays from observable definitions. -fn parse_observable_records(observables_json: &str) -> PyResult>> { - if observables_json.is_empty() { - return Ok(Vec::new()); - } +/// This is the shared factory used by `SampleBatch.decode_count`, +/// `DemSampler.sample_decode_count`, and the parallel variants. +fn create_observable_decoder( + dem: &str, + decoder_type: &str, +) -> PyResult> { + use pecos_decoder_core::{CheckMatrixObservableDecoder, DemCheckMatrix}; + use pecos_decoders::{ + BeliefFindDecoder, BpLsdDecoder, BpMethod, BpOsdDecoder, BpSchedule, InputVectorType, + MinSumBpBuilder, OsdMethod, PyMatchingDecoder, RelayBpBuilder, SparseMatrix, + TesseractConfig, TesseractDecoder, UfMethod, UnionFindDecoder, + }; - let observables: Vec = serde_json::from_str(observables_json) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("Invalid JSON: {e}")))?; + match decoder_type { + "pymatching" => { + // 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) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Box::new(d)) + } + "pymatching_uncorrelated" => { + let d = PyMatchingDecoder::from_dem(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Box::new(d)) + } + "tesseract" => { + let d = TesseractDecoder::new(dem, TesseractConfig::fast()) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Box::new(d)) + } + s if s.starts_with("k_mwpm") => { + // K-MWPM: enumerate K matchings via decoding tree, majority vote. + // Uses UF as the inner MWPM solver (supports decode_with_weights). + use pecos_decoder_core::k_mwpm::{KMwpmConfig, KMwpmDecoder}; + let mut k: usize = 10; + if let Some(params) = s.strip_prefix("k_mwpm:") { + for kv in params.split(',') { + let parts: Vec<&str> = kv.splitn(2, '=').collect(); + if parts.len() == 2 && (parts[0] == "K" || parts[0] == "k") { + k = parts[1].parse().unwrap_or(10); + } + } + } + // Use FB (standard, non-correlated) as the inner MWPM. + // K-MWPM captures correlation benefit by exploring multiple matchings. + let fb = pecos_decoders::FusionBlossomDecoder::from_dem(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Box::new(KMwpmDecoder::new(fb, KMwpmConfig { k }))) + } + "astar" => { + let d = + pecos_decoders::AStarDecoder::from_dem(dem, pecos_decoders::AStarConfig::default()) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + Ok(Box::new(d)) + } + "astar_full" => { + // A* on non-decomposed DEM (preserves hyperedges for Y-error correlations). + let d = pecos_decoders::AStarDecoder::from_dem_full( + dem, + pecos_decoders::AStarConfig::default(), + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Box::new(d)) + } + "fusion_blossom" => { + // Auto: use parallel for large problems (500+ detectors), serial otherwise + let graph = pecos_decoder_core::DemMatchingGraph::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + let has_coords = graph + .detector_coords + .iter() + .any(std::option::Option::is_some); + if graph.num_detectors >= 500 && has_coords { + return create_observable_decoder(dem, "fusion_blossom_parallel"); + } + create_observable_decoder(dem, "fusion_blossom_serial") + } + "fusion_blossom_serial" => { + use pecos_decoder_core::DemMatchingGraph; + use pecos_decoders::{FusionBlossomConfig, FusionBlossomDecoder}; + let graph = DemMatchingGraph::from_dem_str(dem) + .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 + // scaling in add_edge). The upstream tutorial uses relative scaling + // but that loses weight ordering when the range is narrow. + + let config = FusionBlossomConfig { + num_nodes: Some(graph.num_detectors), + num_observables: graph.num_observables, + ..Default::default() + }; + let mut decoder = FusionBlossomDecoder::new(config) + .map_err(|e| PyErr::new::(e.to_string()))?; + for edge in &graph.edges { + let obs: Vec = edge.observables.iter().map(|&o| o as usize).collect(); + let scaled_weight = edge.weight; + match edge.node2 { + Some(n2) => { + decoder + .add_edge(edge.node1 as usize, n2 as usize, &obs, Some(scaled_weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + } + None => { + decoder + .add_boundary_edge(edge.node1 as usize, &obs, Some(scaled_weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + } + } + } + Ok(Box::new(decoder)) + } + "fusion_blossom_correlated" => { + use pecos_decoder_core::DemMatchingGraph; + use pecos_decoder_core::correlation_table::CorrelationTable; + use pecos_decoder_core::two_pass_decoder::TwoPassDecoder; + use pecos_decoders::{FusionBlossomConfig, FusionBlossomDecoder}; + + let graph = DemMatchingGraph::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + + let config = FusionBlossomConfig { + num_nodes: Some(graph.num_detectors), + num_observables: graph.num_observables, + ..Default::default() + }; + let mut decoder = FusionBlossomDecoder::new(config) + .map_err(|e| PyErr::new::(e.to_string()))?; + + // Build edge index map and weights + let mut edge_index_map = std::collections::BTreeMap::new(); + let mut base_weights = Vec::new(); + for (idx, edge) in graph.edges.iter().enumerate() { + let obs: Vec = edge.observables.iter().map(|&o| o as usize).collect(); + base_weights.push(edge.weight); + let key = if let Some(n2) = edge.node2 { + decoder + .add_edge(edge.node1 as usize, n2 as usize, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + if edge.node1 <= n2 { + (edge.node1, n2) + } else { + (n2, edge.node1) + } + } else { + decoder + .add_boundary_edge(edge.node1 as usize, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + (edge.node1, u32::MAX) + }; + edge_index_map.insert(key, idx); + } - let mut observable_records = Vec::with_capacity(observables.len()); + // Build correlation table from DEM decomposition + let corr_table = + CorrelationTable::from_dem_str(dem, &edge_index_map, graph.edges.len()).map_err( + |e| PyErr::new::(e.to_string()), + )?; - for obs in &observables { - let records = obs - .get("records") - .and_then(|r| r.as_array()) - .ok_or_else(|| { - pyo3::exceptions::PyValueError::new_err("Observable missing 'records' array") - })?; + let two_pass = TwoPassDecoder::new(decoder, base_weights, corr_table); + Ok(Box::new(two_pass)) + } + s if s.starts_with("perturbed_fb_corr") => { + // Fast perturbed correlated FB ensemble. + // Parses DemCheckMatrix once, builds K members with perturbed weights + // via from_check_matrix_correlated (skips DEM text re-parsing). + + use pecos_decoder_core::ensemble::EnsembleDecoder; + use pecos_decoders::FusionBlossomDecoder; + + let mut k: usize = 5; + let mut sigma: f64 = 0.5; + let mut seed: u64 = 42; + if let Some(params) = s.strip_prefix("perturbed_fb_corr:") { + for kv in params.split(',') { + let parts: Vec<&str> = kv.splitn(2, '=').collect(); + if parts.len() == 2 { + match parts[0] { + "K" | "k" => k = parts[1].parse().unwrap_or(5), + "sigma" | "s" => sigma = parts[1].parse().unwrap_or(0.5), + "seed" => seed = parts[1].parse().unwrap_or(42), + _ => {} + } + } + } + } - let offsets: Vec = records - .iter() - .map(json_record_offset) - .collect::>>()?; + // Build K members using from_dem_correlated on perturbed DEM text. + // This is faster than the generic perturbed: path because it reuses + // the same DEM parsing approach that FB_corr uses (which handles + // duplicate edges correctly). + let mut members: Vec> = + Vec::with_capacity(k); + + // Unperturbed anchor. + members.push(Box::new( + FusionBlossomDecoder::from_dem_correlated(dem).map_err(|e| { + PyErr::new::(e.to_string()) + })?, + )); - observable_records.push(offsets); - } + // K-1 perturbed members. + let mut rng = pecos_random::PecosRng::seed_from_u64(seed); + let mut next_f64 = || rng.next_f64(); + for _ in 1..k { + let perturbed = + pecos_decoder_core::perturbed::perturb_dem(dem, sigma, &mut next_f64); + if let Ok(dec) = FusionBlossomDecoder::from_dem_correlated(&perturbed) { + members.push(Box::new(dec)); + } + } - Ok(observable_records) -} + Ok(Box::new(EnsembleDecoder::new(members))) + } + "fusion_blossom_parallel" => { + use pecos_decoder_core::DemMatchingGraph; + use pecos_decoders::{FusionBlossomConfig, FusionBlossomDecoder, PartitionConfig}; + + let graph = DemMatchingGraph::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + + // Group detectors by time coordinate for round-contiguous relabeling. + let mut round_groups: std::collections::BTreeMap> = + std::collections::BTreeMap::new(); + #[allow(clippy::cast_possible_truncation)] // time coords and detector IDs are small + for (id, coord) in graph.detector_coords.iter().enumerate() { + let t = coord + .as_ref() + .and_then(|c| c.get(2)) + .copied() + .unwrap_or(0.0); + round_groups + .entry((t * 1000.0) as i64) + .or_default() + .push(id as u32); + } + let num_rounds = round_groups.len(); + if num_rounds < 2 { + // Not enough rounds to partition -- fall back to serial + return create_observable_decoder(dem, "fusion_blossom"); + } -// ============================================================================= -// Measurement Noise Model -// ============================================================================= + // Relabel: each round gets [detectors] [boundary_virtual] contiguously. + // This ensures boundary edges stay within the same vertex range as + // the round's detectors. + let num_dets = graph.num_detectors; + let mut old_to_new = vec![0usize; num_dets]; + let mut det_to_round = vec![0usize; num_dets]; + let mut new_id = 0usize; + let mut round_starts = Vec::new(); + let mut round_ends = Vec::new(); // end of each round (after boundary vertex) + let mut partition_boundary = Vec::new(); + for (round_idx, (_round, ids)) in round_groups.iter().enumerate() { + round_starts.push(new_id); + for &old_id in ids { + old_to_new[old_id as usize] = new_id; + det_to_round[old_id as usize] = round_idx; + new_id += 1; + } + // Virtual boundary vertex for this round, right after detectors + partition_boundary.push(new_id); + new_id += 1; + round_ends.push(new_id); + } + let total_vertex_num = new_id; + + let config = FusionBlossomConfig { + num_nodes: Some(total_vertex_num), + num_observables: graph.num_observables, + ..Default::default() + }; + let mut decoder = FusionBlossomDecoder::new(config) + .map_err(|e| PyErr::new::(e.to_string()))?; + // Mark per-partition boundary vertices as virtual + for &bnd in &partition_boundary { + decoder.virtual_vertices.push(bnd); + } -/// A Measurement Noise Model (MNM) for fast approximate sampling. -/// -/// Unlike a DEM which maps error mechanisms to detector effects, the MNM maps -/// directly to measurement effects. This allows sampling raw measurement outcomes -/// without needing detector definitions. -/// -/// # Sampling Modes -/// -/// - **Per-fault-location** (accurate): Sample each (location, Pauli) independently -/// - **Per-mechanism** (fast, approximate): Sample each unique measurement effect once -/// -/// The MNM enables the fast per-mechanism mode while still producing raw measurement -/// outcomes that can be converted to detection events using any detector definition. -/// -/// # Example -/// -/// ```python -/// from pecos_rslib.qec import MemBuilder -/// -/// # Build MNM from influence map -/// mnm = MemBuilder(influence_map).with_noise(0.01, 0.01, 0.01, 0.01).build() -/// -/// # Sample measurement outcomes -/// outcomes = mnm.sample() -/// ``` -#[pyclass(name = "MeasurementNoiseModel", module = "pecos_rslib.qec")] -pub struct PyMeasurementNoiseModel { - inner: RustMeasurementNoiseModel, -} + for edge in &graph.edges { + let obs: Vec = edge.observables.iter().map(|&o| o as usize).collect(); + let n1 = old_to_new[edge.node1 as usize]; + if let Some(n2) = edge.node2 { + let n2 = old_to_new[n2 as usize]; + decoder + .add_edge(n1, n2, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + } else { + // Route boundary edge to this detector's round boundary vertex + let round_idx = det_to_round[edge.node1 as usize]; + let bnd = partition_boundary[round_idx]; + decoder + .add_edge(n1, bnd, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + } + } -#[pymethods] -impl PyMeasurementNoiseModel { - /// Number of distinct mechanisms in the model. - #[getter] - fn num_mechanisms(&self) -> usize { - self.inner.num_mechanisms() - } + // Build partition config matching the upstream time-partition pattern. + // Each partition covers multiple rounds. The interface between + // adjacent partitions is the first round of the later partition's + // rounds (which is skipped from its range, creating the gap). + + // Partition ranges: each partition covers multiple rounds of detectors + // plus that partition's boundary vertices. + // Boundary vertices are at indices [num_dets, num_dets + num_rounds). + // We assign boundary vertex for round R to the partition that contains round R. + let partition_num = num_rounds.clamp(2, 4); + + // Build partition config. Each partition covers multiple rounds. + // Partition boundaries fall between rounds. The first round of + // each non-first partition is the interface gap (its vertices + // are excluded from the partition range). + let mut part_config = PartitionConfig::new(total_vertex_num); + part_config.partitions.clear(); + + for p_idx in 0..partition_num { + let start_round = p_idx * num_rounds / partition_num; + let end_round = (p_idx + 1) * num_rounds / partition_num; + // First partition starts at its first round. + // Subsequent partitions skip their first round (interface gap). + let start_vertex = if p_idx == 0 { + round_starts[start_round] + } else { + round_starts[(start_round + 1).min(num_rounds - 1)] + }; + let end_vertex = round_ends[end_round - 1]; + if start_vertex < end_vertex { + part_config + .partitions + .push(pecos_decoders::VertexRange::new(start_vertex, end_vertex)); + } + } - /// Number of measurements in the circuit. - #[getter] - fn num_measurements(&self) -> usize { - self.inner.num_measurements - } + // Linear fusion chain: merge adjacent partitions left to right + let n_parts = part_config.partitions.len(); + part_config.fusions.clear(); + if n_parts > 1 { + let mut active: Vec = (0..n_parts).collect(); + while active.len() > 1 { + let mut next_active = Vec::new(); + let mut i = 0; + while i + 1 < active.len() { + part_config.fusions.push((active[i], active[i + 1])); + next_active.push(n_parts + part_config.fusions.len() - 1); + i += 2; + } + if i < active.len() { + next_active.push(active[i]); + } + active = next_active; + } + } - /// Sample measurement outcomes. - /// - /// Each noise mechanism is sampled once according to its probability. - /// When a mechanism fires, its measurements are XOR'd into the outcomes. - /// - /// Args: - /// seed: Optional random seed for reproducibility. - /// - /// Returns: - /// List of boolean measurement outcomes. - #[pyo3(signature = (seed=None))] - fn sample(&self, seed: Option) -> Vec { - use pecos_random::PecosRng; - use rand::RngExt; + decoder.set_partition_config(part_config); - let mut rng = match seed { - Some(s) => PecosRng::seed_from_u64(s), - None => PecosRng::seed_from_u64(rand::rng().random()), - }; + Ok(Box::new(RelabeledObservableDecoder { + decoder, + old_to_new, + })) + } + "bp_osd" | "bp_lsd" | "belief_find" | "union_find" | "relay_bp" | "min_sum_bp" => { + let dcm = DemCheckMatrix::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + let sparse_h = SparseMatrix::from_dense(&dcm.check_matrix.view()); + match decoder_type { + "bp_osd" => { + let d = BpOsdDecoder::new( + &sparse_h, + None, + Some(&dcm.error_priors), + 100, + BpMethod::ProductSum, + BpSchedule::Parallel, + 1.0, + OsdMethod::Osd0, + 0, + InputVectorType::Syndrome, + None, + None, + None, + ) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + Ok(Box::new(CheckMatrixObservableDecoder::new(d, dcm))) + } + "bp_lsd" => { + let d = BpLsdDecoder::new( + &sparse_h, + None, + Some(&dcm.error_priors), + 100, + BpMethod::ProductSum, + BpSchedule::Parallel, + 1.0, + OsdMethod::Off, + 0, + 0, + InputVectorType::Syndrome, + None, + None, + None, + ) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + Ok(Box::new(CheckMatrixObservableDecoder::new(d, dcm))) + } + "belief_find" => { + let d = BeliefFindDecoder::new( + &sparse_h, + None, + Some(&dcm.error_priors), + 100, + BpMethod::ProductSum, + 1.0, + BpSchedule::Parallel, + None, + None, + None, + UfMethod::Inversion, + 0, + ) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + Ok(Box::new(CheckMatrixObservableDecoder::new(d, dcm))) + } + "union_find" => { + let d = UnionFindDecoder::new(&sparse_h, UfMethod::Inversion).map_err(|e| { + PyErr::new::(e.to_string()) + })?; + let llrs: Vec = dcm + .error_priors + .iter() + .map(|&p| { + if p > 0.0 && p < 1.0 { + ((1.0 - p) / p).ln() + } else { + 0.0 + } + }) + .collect(); + Ok(Box::new(WeightedUfObservableDecoder { + decoder: d, + dcm, + llrs, + })) + } + "relay_bp" => { + let h_view = dcm.check_matrix.view(); + let d = RelayBpBuilder::new(&h_view) + .error_priors(&dcm.error_priors) + .build() + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + Ok(Box::new(CheckMatrixObservableDecoder::new(d, dcm))) + } + "min_sum_bp" => { + let h_view = dcm.check_matrix.view(); + let d = MinSumBpBuilder::new(&h_view) + .error_priors(&dcm.error_priors) + .build() + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + Ok(Box::new(CheckMatrixObservableDecoder::new(d, dcm))) + } + _ => unreachable!(), + } + } + // UF decoder: "pecos_uf" (fast), "pecos_uf:balanced", "pecos_uf:accurate" + // Also accepts legacy "pecos_uf_correlated" as alias for balanced. + "pecos_uf" | "pecos_uf:fast" => { + let d = + pecos_decoders::UfDecoder::from_dem(dem, pecos_decoders::UfDecoderConfig::fast()) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Box::new(d)) + } + "pecos_uf:balanced" | "pecos_uf_correlated" => { + // Two-pass correlated UF: first pass identifies matched edges, + // correlation table adjusts weights, second pass re-decodes. + use pecos_decoder_core::DemMatchingGraph; + use pecos_decoder_core::correlation_table::CorrelationTable; + use pecos_decoder_core::two_pass_decoder::TwoPassDecoder; + + let graph = DemMatchingGraph::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + + let mut edge_index_map = std::collections::BTreeMap::new(); + let mut base_weights = Vec::with_capacity(graph.edges.len()); + for (idx, edge) in graph.edges.iter().enumerate() { + base_weights.push(edge.weight); + let key = match edge.node2 { + Some(n2) => { + if edge.node1 <= n2 { + (edge.node1, n2) + } else { + (n2, edge.node1) + } + } + None => (edge.node1, u32::MAX), + }; + edge_index_map.insert(key, idx); + } - self.inner.sample(&mut rng) - } + let corr_table = + CorrelationTable::from_dem_str(dem, &edge_index_map, graph.edges.len()).map_err( + |e| PyErr::new::(e.to_string()), + )?; + + let uf = pecos_decoders::UfDecoder::from_matching_graph( + &graph, + pecos_decoders::UfDecoderConfig::balanced(), + ); + let two_pass = TwoPassDecoder::new(uf, base_weights, corr_table); + Ok(Box::new(two_pass)) + } + "pecos_uf:bp" => { + // BP+UF hybrid: flooding BP (fast, good for d<=7). + let d = + pecos_decoders::BpUfDecoder::from_dem(dem, pecos_decoders::BpUfConfig::balanced()) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + Ok(Box::new(d)) + } + "belief_matching_mgbp" => { + // Belief-matching with matching-graph BP (Hack et al. 2026 style). + // BP runs on the matching graph (simpler, better convergence) + // instead of the Tanner graph. + use pecos_decoder_core::DemMatchingGraph; + use pecos_decoder_core::bp_matching::BpMatchingDecoder; + use pecos_decoder_core::correlation_table::CorrelationTable; + use pecos_decoders::{FusionBlossomConfig, FusionBlossomDecoder}; + + let bp = pecos_decoders::BpUfDecoder::from_dem( + dem, + pecos_decoders::BpUfConfig::matching_bp(), + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + + let graph = DemMatchingGraph::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + let config = FusionBlossomConfig { + num_nodes: Some(graph.num_detectors), + num_observables: graph.num_observables, + ..Default::default() + }; + let mut fb = FusionBlossomDecoder::new(config) + .map_err(|e| PyErr::new::(e.to_string()))?; + let mut edge_index_map = std::collections::BTreeMap::new(); + for (idx, edge) in graph.edges.iter().enumerate() { + let obs: Vec = edge.observables.iter().map(|&o| o as usize).collect(); + let key = if let Some(n2) = edge.node2 { + fb.add_edge(edge.node1 as usize, n2 as usize, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + if edge.node1 <= n2 { + (edge.node1, n2) + } else { + (n2, edge.node1) + } + } else { + fb.add_boundary_edge(edge.node1 as usize, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + (edge.node1, u32::MAX) + }; + edge_index_map.insert(key, idx); + } + let corr_table = + CorrelationTable::from_dem_str(dem, &edge_index_map, graph.edges.len()).map_err( + |e| PyErr::new::(e.to_string()), + )?; + Ok(Box::new(BpMatchingDecoder::with_correlations( + fb, bp, corr_table, + ))) + } + "pecos_uf:bp_serial" => { + // BP+UF hybrid: serial BP (slower, maintains threshold at d=7-11+). + let d = + pecos_decoders::BpUfDecoder::from_dem(dem, pecos_decoders::BpUfConfig::accurate()) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + Ok(Box::new(d)) + } + "belief_matching" => { + // Belief-matching: BP soft info → Fusion Blossom MWPM with dynamic weights. + // Achieves ~0.94% circuit-level threshold (Higgott 2022). + use pecos_decoder_core::DemMatchingGraph; + use pecos_decoder_core::bp_matching::BpMatchingDecoder; + use pecos_decoders::{FusionBlossomConfig, FusionBlossomDecoder}; + + // Build BP weight provider. + let bp = + pecos_decoders::BpUfDecoder::from_dem(dem, pecos_decoders::BpUfConfig::balanced()) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + + // Build Fusion Blossom as the matching backend. + let graph = DemMatchingGraph::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + let config = FusionBlossomConfig { + num_nodes: Some(graph.num_detectors), + num_observables: graph.num_observables, + ..Default::default() + }; + let mut fb = FusionBlossomDecoder::new(config) + .map_err(|e| PyErr::new::(e.to_string()))?; + for edge in &graph.edges { + let obs: Vec = edge.observables.iter().map(|&o| o as usize).collect(); + match edge.node2 { + Some(n2) => { + fb.add_edge(edge.node1 as usize, n2 as usize, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + } + None => { + fb.add_boundary_edge(edge.node1 as usize, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + } + } + } - /// Sample multiple shots of measurement outcomes. - /// - /// Args: - /// `num_shots`: Number of shots to sample. - /// seed: Optional random seed for reproducibility. - /// - /// Returns: - /// List of lists, where each inner list contains boolean measurement outcomes. - #[pyo3(signature = (num_shots, seed=None))] - fn sample_batch(&self, num_shots: usize, seed: Option) -> Vec> { - use pecos_random::PecosRng; - use rand::RngExt; + Ok(Box::new(BpMatchingDecoder::new(fb, bp))) + } + "belief_matching_correlated" => { + // Correlated belief-matching: BP + correlation table + Fusion Blossom MWPM. + // Two-pass: BP weights → MWPM → correlation adjustment → MWPM. + // Combines BP soft info with X-Z cross-lattice correlations. + use pecos_decoder_core::DemMatchingGraph; + use pecos_decoder_core::bp_matching::BpMatchingDecoder; + use pecos_decoder_core::correlation_table::CorrelationTable; + use pecos_decoders::{FusionBlossomConfig, FusionBlossomDecoder}; + + let bp = + pecos_decoders::BpUfDecoder::from_dem(dem, pecos_decoders::BpUfConfig::balanced()) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + + let graph = DemMatchingGraph::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + + // Build Fusion Blossom. + let config = FusionBlossomConfig { + num_nodes: Some(graph.num_detectors), + num_observables: graph.num_observables, + ..Default::default() + }; + let mut fb = FusionBlossomDecoder::new(config) + .map_err(|e| PyErr::new::(e.to_string()))?; + + let mut edge_index_map = std::collections::BTreeMap::new(); + for (idx, edge) in graph.edges.iter().enumerate() { + let obs: Vec = edge.observables.iter().map(|&o| o as usize).collect(); + let key = if let Some(n2) = edge.node2 { + fb.add_edge(edge.node1 as usize, n2 as usize, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + if edge.node1 <= n2 { + (edge.node1, n2) + } else { + (n2, edge.node1) + } + } else { + fb.add_boundary_edge(edge.node1 as usize, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + (edge.node1, u32::MAX) + }; + edge_index_map.insert(key, idx); + } - let mut rng = match seed { - Some(s) => PecosRng::seed_from_u64(s), - None => PecosRng::seed_from_u64(rand::rng().random()), - }; + // Build correlation table from decomposed DEM. + let corr_table = + CorrelationTable::from_dem_str(dem, &edge_index_map, graph.edges.len()).map_err( + |e| PyErr::new::(e.to_string()), + )?; - (0..num_shots) - .map(|_| self.inner.sample(&mut rng)) - .collect() - } + Ok(Box::new(BpMatchingDecoder::with_correlations( + fb, bp, corr_table, + ))) + } + s if s.starts_with("belief_matching_hybrid:") => { + // Hybrid correlated belief-matching: non-decomposed DEM for BP, + // decomposed DEM for matching graph + correlations. + // Format: "belief_matching_hybrid:" + // The main `dem` param is the decomposed DEM. + use pecos_decoder_core::DemMatchingGraph; + use pecos_decoder_core::bp_matching::BpMatchingDecoder; + use pecos_decoder_core::correlation_table::CorrelationTable; + use pecos_decoders::{FusionBlossomConfig, FusionBlossomDecoder}; + + let full_dem = &s["belief_matching_hybrid:".len()..]; + + // BP uses non-decomposed DEM, matching uses decomposed. + let bp = pecos_decoders::BpUfDecoder::from_dual_dem( + full_dem, + dem, + pecos_decoders::BpUfConfig::balanced(), + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + + // Build Fusion Blossom from decomposed DEM. + let graph = DemMatchingGraph::from_dem_str(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + let config = FusionBlossomConfig { + num_nodes: Some(graph.num_detectors), + num_observables: graph.num_observables, + ..Default::default() + }; + let mut fb = FusionBlossomDecoder::new(config) + .map_err(|e| PyErr::new::(e.to_string()))?; + let mut edge_index_map = std::collections::BTreeMap::new(); + for (idx, edge) in graph.edges.iter().enumerate() { + let obs: Vec = edge.observables.iter().map(|&o| o as usize).collect(); + let key = if let Some(n2) = edge.node2 { + fb.add_edge(edge.node1 as usize, n2 as usize, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + if edge.node1 <= n2 { + (edge.node1, n2) + } else { + (n2, edge.node1) + } + } else { + fb.add_boundary_edge(edge.node1 as usize, &obs, Some(edge.weight)) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + (edge.node1, u32::MAX) + }; + edge_index_map.insert(key, idx); + } + let corr_table = + CorrelationTable::from_dem_str(dem, &edge_index_map, graph.edges.len()).map_err( + |e| PyErr::new::(e.to_string()), + )?; + + Ok(Box::new(BpMatchingDecoder::with_correlations( + fb, bp, corr_table, + ))) + } + s if s.starts_with("windowed") => { + // Windowed decoder: "windowed" or "windowed:step=N,buf=M,inner=TYPE,mode=MODE" + // inner= takes the REST of the string (supports nested specs with commas). + let mut config = pecos_decoders::WindowedConfig::default(); + let mut inner_type = "pecos_uf".to_string(); + let mut mode = String::new(); + if let Some(params) = s.strip_prefix("windowed:") { + // Split inner= from the rest: "step=5,buf=5,inner=perturbed:K=7,sigma=0.5" + // → params before inner, inner spec + let (own_params, inner_spec) = if let Some(idx) = params.find(",inner=") { + (¶ms[..idx], Some(¶ms[idx + 7..])) + } else if let Some(idx) = params.find("inner=") { + (¶ms[..idx.saturating_sub(1)], Some(¶ms[idx + 6..])) + } else { + (params, None) + }; + if let Some(spec) = inner_spec { + inner_type = spec.to_string(); + } + for kv in own_params.split(',') { + let parts: Vec<&str> = kv.splitn(2, '=').collect(); + if parts.len() == 2 { + match parts[0] { + "step" => config.step_size = parts[1].parse().unwrap_or(0), + "buf" | "buffer" => config.buffer_size = parts[1].parse().unwrap_or(0), + "mode" => mode = parts[1].to_string(), + "seam" => config.seam_half_width = parts[1].parse().unwrap_or(0), + "ext" | "core_extend" => { + config.core_extend = parts[1].parse().unwrap_or(0); + } + "wmax" | "commit_weight_max" => { + config.commit_weight_max = parts[1].parse().unwrap_or(0.0); + } + _ => {} + } + } + } + } - /// Get all mechanisms and their probabilities. - /// - /// Returns: - /// List of (measurements, probability) tuples. - fn get_mechanisms(&self) -> Vec<(Vec, f64)> { - self.inner + if mode == "sandwich" || (mode.is_empty() && config.buffer_size > 0) { + // Sandwich decoder (two-phase): best accuracy with buf > 0. + // Default: buf=step, wmax=2.5, PM residual decoder. + if config.buffer_size == 0 { + config.buffer_size = config.step_size; + } + if config.commit_weight_max == 0.0 { + config.commit_weight_max = 2.5; + } + let phase2_type = if inner_type == "pecos_uf" { + "pymatching".to_string() + } else { + inner_type.clone() + }; + let phase1_factory = |sub_dem: &str| -> Result< + pecos_decoders::UfDecoder, + pecos_decoders::DecoderError, + > { + pecos_decoders::UfDecoder::from_dem( + sub_dem, + pecos_decoders::UfDecoderConfig::windowed(), + ) + }; + let phase2_factory = |sub_dem: &str| -> Result< + Box, + pecos_decoders::DecoderError, + > { + create_observable_decoder(sub_dem, &phase2_type) + .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string())) + }; + let dec = pecos_decoders::SandwichWindowedDecoder::from_dem( + dem, + config, + phase1_factory, + phase2_factory, + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Box::new(dec)) + } else if mode == "overlap" { + // Single-phase overlapping (UF by default). + let factory = |sub_dem: &str| -> Result< + pecos_decoders::UfDecoder, + pecos_decoders::DecoderError, + > { + pecos_decoders::UfDecoder::from_dem( + sub_dem, + pecos_decoders::UfDecoderConfig::windowed(), + ) + }; + let dec = + pecos_decoders::OverlappingWindowedDecoder::from_dem(dem, config, factory) + .map_err(|e| { + PyErr::new::(e.to_string()) + })?; + Ok(Box::new(dec)) + } else { + // Non-overlapping with pluggable inner decoder. + let factory = |sub_dem: &str| -> Result< + Box, + pecos_decoders::DecoderError, + > { + create_observable_decoder(sub_dem, &inner_type) + .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string())) + }; + let dec = pecos_decoders::WindowedDecoder::from_dem(dem, config, factory).map_err( + |e| PyErr::new::(e.to_string()), + )?; + Ok(Box::new(dec)) + } + } + "pecos_uf:accurate" => { + // UIUF CSS-aware mode. Single-DEM path falls back to balanced. + // For proper UIUF, use CssUfDecoder directly with separate X/Z DEMs + // via the PyCssUfDecoder Python class. + create_observable_decoder(dem, "pecos_uf:balanced") + } + "mwpf" => { + let d = + pecos_decoders::MwpfDecoder::from_dem(dem, pecos_decoders::MwpfConfig::default()) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Box::new(d)) + } + s if s.starts_with("mwpf:") => { + // Parse "mwpf:key=val,key=val" config overrides. + // Keys: c/cluster_node_limit, t/timeout, once/only_solve_primal_once, solver + let mut config = pecos_decoders::MwpfConfig::default(); + for kv in s[5..].split(',') { + let parts: Vec<&str> = kv.splitn(2, '=').collect(); + if parts.len() != 2 { + continue; + } + match parts[0] { + "c" | "cluster_node_limit" => { + config.cluster_node_limit = parts[1].parse().unwrap_or(50); + } + "t" | "timeout" => { + config.timeout = parts[1].parse().ok(); + } + "once" | "only_solve_primal_once" => { + config.only_solve_primal_once = parts[1] == "true" || parts[1] == "1"; + } + "solver" => { + config.solver_type = match parts[1] { + "uf" | "union_find" => pecos_decoders::MwpfSolverType::UnionFind, + "sh" | "single_hair" => pecos_decoders::MwpfSolverType::SingleHair, + "bp" | "bp_hybrid" => pecos_decoders::MwpfSolverType::BpHybrid, + _ => pecos_decoders::MwpfSolverType::JointSingleHair, + }; + } + _ => {} + } + } + let d = pecos_decoders::MwpfDecoder::from_dem(dem, config) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Box::new(d)) + } + s if s.starts_with("perturbed") => { + // Perturbed-weight ensemble: "perturbed" or "perturbed:K=15,sigma=0.7,inner=TYPE" + // inner= takes the REST of the string (supports nested decoder specs). + use pecos_decoder_core::perturbed::{PerturbedConfig, build_perturbed_ensemble}; + + let mut config = PerturbedConfig::default(); + let mut inner_type = "pymatching".to_string(); + if let Some(params) = s.strip_prefix("perturbed:") { + // Extract inner= (takes rest of string for nesting support) + let (own_params, inner_spec) = if let Some(idx) = params.find(",inner=") { + (¶ms[..idx], Some(¶ms[idx + 7..])) + } else if let Some(idx) = params.find("inner=") { + (¶ms[..idx.saturating_sub(1)], Some(¶ms[idx + 6..])) + } else { + (params, None) + }; + if let Some(spec) = inner_spec { + inner_type = spec.to_string(); + } + for kv in own_params.split(',') { + let parts: Vec<&str> = kv.splitn(2, '=').collect(); + if parts.len() == 2 { + match parts[0] { + "K" | "k" => config.k = parts[1].parse().unwrap_or(15), + "sigma" | "s" => config.sigma = parts[1].parse().unwrap_or(0.7), + "seed" => config.seed = parts[1].parse().unwrap_or(42), + _ => {} + } + } + } + } + + let ensemble = build_perturbed_ensemble(dem, &config, |sub_dem| { + create_observable_decoder(sub_dem, &inner_type) + .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string())) + }) + .map_err(|e| PyErr::new::(e.to_string()))?; + + Ok(Box::new(ensemble)) + } + s if s.starts_with("beamsearch") => { + // Beam search windowed decoder: "beamsearch" or "beamsearch:K=5,sigma=0.5,buf=5" + let mut config = pecos_decoders::BeamSearchConfig::default(); + if let Some(params) = s.strip_prefix("beamsearch:") { + for kv in params.split(',') { + let parts: Vec<&str> = kv.splitn(2, '=').collect(); + if parts.len() == 2 { + match parts[0] { + "K" | "k" => config.beam_width = parts[1].parse().unwrap_or(5), + "sigma" | "s" => { + config.perturbation_sigma = parts[1].parse().unwrap_or(0.5); + } + "seed" => config.seed = parts[1].parse().unwrap_or(42), + "step" => config.window.step_size = parts[1].parse().unwrap_or(0), + "buf" | "buffer" => { + config.window.buffer_size = parts[1].parse().unwrap_or(0); + } + "wmax" => { + config.window.commit_weight_max = parts[1].parse().unwrap_or(0.0); + } + _ => {} + } + } + } + } + // Match sandwich defaults: buf=step, wmax=2.5. + // When step=0 (auto), buf also needs to be auto. Set buf=5 as a + // reasonable default (will be auto-tuned by parse_dem_params). + if config.window.buffer_size == 0 { + if config.window.step_size > 0 { + config.window.buffer_size = config.window.step_size; + } else { + config.window.buffer_size = 5; // auto: will be refined by d_est + } + } + if config.window.commit_weight_max == 0.0 { + config.window.commit_weight_max = 2.5; + } + + let phase1_factory = |sub_dem: &str| -> Result< + pecos_decoders::UfDecoder, + pecos_decoders::DecoderError, + > { + pecos_decoders::UfDecoder::from_dem( + sub_dem, + pecos_decoders::UfDecoderConfig::windowed(), + ) + }; + let phase2_factory = |sub_dem: &str| -> Result< + Box, + pecos_decoders::DecoderError, + > { + create_observable_decoder(sub_dem, "pymatching") + .map_err(|e| pecos_decoders::DecoderError::InternalError(e.to_string())) + }; + let dec = pecos_decoders::BeamSearchWindowedDecoder::from_dem( + dem, + config, + phase1_factory, + Some(phase2_factory), + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Box::new(dec)) + } + s if s.starts_with("ensemble:") => { + // Parse "ensemble:dec1,dec2,dec3" -- create multiple decoders and vote. + use pecos_decoder_core::ensemble::EnsembleDecoder; + let members_str = &s[9..]; + let mut members: Vec> = Vec::new(); + for spec in members_str.split(',') { + let spec = spec.trim(); + if spec.is_empty() { + continue; + } + members.push(create_observable_decoder(dem, spec)?); + } + if members.is_empty() { + return Err(PyErr::new::( + "ensemble: needs at least one decoder", + )); + } + Ok(Box::new(EnsembleDecoder::new(members))) + } + // Per-observable 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:") => { + Err(PyErr::new::( + "observable_subgraph decoder requires stab_coords. \ + Use pecos_rslib.qec.ObservableSubgraphDecoder 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." + ))), + } +} + +/// Pre-generated sample batch held in Rust memory. +/// +/// Created by `DemSampler.generate_samples()`. Can be decoded by multiple +/// decoders without re-sampling, and without crossing the Rust/Python boundary +/// per shot. +/// +/// # Example +/// +/// ```python +/// samples = sampler.generate_samples(10000, seed=42) +/// pm_errors = samples.decode_count(dem, "pymatching") +/// ts_errors = samples.decode_count(dem, "tesseract") +/// # Both decoders ran on the exact same samples. +/// ``` +#[pyclass(name = "SampleBatch", module = "pecos_rslib.qec")] +pub struct PySampleBatch { + /// Columnar bit-packed detector columns: det_columns[det_idx][word_idx] + det_columns: Vec>, + /// Columnar bit-packed observable columns: obs_columns[obs_idx][word_idx] + obs_columns: Vec>, + num_detectors: usize, + num_shots: usize, +} + +impl PySampleBatch { + /// Extract syndrome for one shot into a pre-allocated buffer. + fn extract_syndrome(&self, shot: usize, buf: &mut [u8]) { + buf.fill(0); + let word_idx = shot / 64; + let bit_mask = 1u64 << (shot % 64); + for (det_idx, col) in self.det_columns.iter().enumerate() { + if col[word_idx] & bit_mask != 0 { + buf[det_idx] = 1; + } + } + } + + /// Extract observable mask for one shot. + fn extract_obs_mask(&self, shot: usize) -> u64 { + let word_idx = shot / 64; + let bit_mask = 1u64 << (shot % 64); + let mut mask = 0u64; + for (obs_idx, col) in self.obs_columns.iter().enumerate() { + if col[word_idx] & bit_mask != 0 { + mask |= 1u64 << obs_idx; + } + } + mask + } + + /// Build from columnar data (from generate_samples). + fn from_columnar( + det_columns: Vec>, + obs_columns: Vec>, + num_shots: usize, + ) -> Self { + let num_detectors = det_columns.len(); + Self { + det_columns, + obs_columns, + num_detectors, + num_shots, + } + } + + /// Build from row-major data (from Python constructor). + fn from_row_major(detection_events: Vec>, observable_masks: Vec) -> 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); + + // Convert row-major → columnar + let mut det_columns = vec![vec![0u64; num_words]; num_detectors]; + for (shot, row) in detection_events.iter().enumerate() { + let word_idx = shot / 64; + let bit_mask = 1u64 << (shot % 64); + for (det_idx, &val) in row.iter().enumerate() { + if val != 0 { + det_columns[det_idx][word_idx] |= bit_mask; + } + } + } + + // Find max observable index + let max_obs = observable_masks .iter() - .map(|(m, &p)| (m.measurements.to_vec(), p)) - .collect() + .map(|m| 64 - m.leading_zeros() as usize) + .max() + .unwrap_or(0); + let mut obs_columns = vec![vec![0u64; num_words]; max_obs]; + 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; + } + } + } + + Self { + det_columns, + obs_columns, + num_detectors, + num_shots, + } } +} - /// Convert measurement outcomes to detection events. +#[pymethods] +impl PySampleBatch { + /// Build a SampleBatch from detection event arrays and observable masks. /// - /// Given raw measurement outcomes and detector definitions, computes which - /// detectors fire by XOR'ing the specified measurement records for each detector. + /// Args: + /// detection_events: List of syndromes, each a list of u8 (0/1). + /// observable_masks: List of u64 true observable flip masks. + #[new] + #[pyo3(signature = (detection_events, observable_masks))] + 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", + detection_events.len(), + observable_masks.len(), + ))); + } + let expected_len = detection_events.first().map_or(0, Vec::len); + for (i, row) in detection_events.iter().enumerate() { + if row.len() != expected_len { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "detection_events row {i} has length {} but expected {expected_len} \ + (matching row 0)", + row.len() + ))); + } + } + Ok(Self::from_row_major(detection_events, observable_masks)) + } + + /// Number of shots in this batch. + #[getter] + fn num_shots(&self) -> usize { + self.num_shots + } + + /// Get the syndrome for shot `i` as a list of u8 values. + fn get_syndrome(&self, i: usize) -> PyResult> { + if i >= self.num_shots { + return Err(PyErr::new::(format!( + "Shot index {i} out of range (num_shots={})", + self.num_shots + ))); + } + let mut buf = vec![0u8; self.num_detectors]; + self.extract_syndrome(i, &mut buf); + Ok(buf) + } + + /// Get the expected observable mask for shot `i`. + fn get_observable_mask(&self, i: usize) -> PyResult { + if i >= self.num_shots { + return Err(PyErr::new::(format!( + "Shot index {i} out of range (num_shots={})", + self.num_shots + ))); + } + Ok(self.extract_obs_mask(i)) + } + + /// Decode all samples with the given decoder type and return the error count. /// - /// If measurement order was provided when building the MNM, outcomes are first - /// reordered from influence map order to `TickCircuit` order before applying - /// detector records. + /// This runs entirely in Rust -- no per-shot Python crossing. /// /// Args: - /// outcomes: List of boolean measurement outcomes (from `sample()`). - /// `detectors_json`: JSON string with detector definitions. - /// Format: [{"id": 0, "records": [-1, -5]}, ...] - /// Public surface descriptors using "`detector_id`" are also accepted. - /// Records are negative offsets from end of measurement list. + /// 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`". /// /// Returns: - /// List of boolean detection events (True = detector fired). - /// - /// Example: - /// >>> outcomes = `mnm.sample()` - /// >>> `detection_events` = `mnm.to_detection_events(outcomes`, `detectors_json`) - fn to_detection_events( - &self, - outcomes: Vec, - detectors_json: &str, - ) -> PyResult> { - let detector_records = parse_detector_records(detectors_json)?; - // Use instance method which applies im_to_tc reordering if set - Ok(self - .inner - .compute_detection_events(&outcomes, &detector_records)) + /// Number of logical errors. + #[pyo3(signature = (dem, decoder_type="pymatching"))] + fn decode_count(&self, dem: &str, decoder_type: &str) -> PyResult { + 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) { + errors += 1; + } + } + Ok(errors) } - /// Sample and convert to detection events in one step. + /// Parallel decode: distributes samples across rayon workers. /// - /// This is a convenience method that combines `sample()` and `to_detection_events()`. + /// Each worker creates its own decoder instance. Faster for slow decoders. /// /// Args: - /// `detectors_json`: JSON string with detector definitions. - /// seed: Optional random seed for reproducibility. + /// dem: DEM string for the decoder. + /// `decoder_type`: Decoder type string. + /// `num_workers`: Number of parallel workers (default: number of CPUs). /// /// Returns: - /// Tuple of (`measurement_outcomes`, `detection_events`). - #[pyo3(signature = (detectors_json, seed=None))] - fn sample_with_detectors( + /// Number of logical errors. + #[pyo3(signature = (dem, decoder_type="pymatching", num_workers=None))] + fn decode_count_parallel( &self, - detectors_json: &str, - seed: Option, - ) -> PyResult<(Vec, Vec)> { - use pecos_random::PecosRng; - use rand::RngExt; - - let detector_records = parse_detector_records(detectors_json)?; - - let mut rng = match seed { - Some(s) => PecosRng::seed_from_u64(s), - None => PecosRng::seed_from_u64(rand::rng().random()), - }; + dem: &str, + decoder_type: &str, + num_workers: Option, + ) -> PyResult { + use rayon::prelude::*; + + let n_workers = num_workers.unwrap_or_else(rayon::current_num_threads); + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(n_workers) + .build() + .map_err(|e| PyErr::new::(e.to_string()))?; + + let dem_str = dem.to_string(); + let dt = decoder_type.to_string(); + let n = self.num_shots; + let num_dets = self.num_detectors; + + // Materialize row-major data for parallel decode. + let detection_events: Vec> = (0..n) + .map(|i| { + let mut s = vec![0u8; num_dets]; + self.extract_syndrome(i, &mut s); + s + }) + .collect(); + let observable_masks: Vec = (0..n).map(|i| self.extract_obs_mask(i)).collect(); + + let total_errors: usize = pool.install(|| { + (0..n) + .into_par_iter() + .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]) + }, + ) + .sum() + }); - let (outcomes, detection_events) = self - .inner - .sample_with_detectors(&detector_records, &mut rng); - Ok((outcomes, detection_events)) + Ok(total_errors) } - /// Sample multiple shots and convert to detection events. + /// Batch decode all samples at once using `PyMatching`'s batch API. /// - /// Args: - /// `num_shots`: Number of shots to sample. - /// `detectors_json`: JSON string with detector definitions. - /// seed: Optional random seed for reproducibility. + /// Sends all detection events in a single flat array to the decoder, + /// which can vectorize across shots. Faster than per-shot decode for + /// `PyMatching`. Only supports pymatching decoder. /// /// Returns: - /// List of (`measurement_outcomes`, `detection_events`) tuples. - #[pyo3(signature = (num_shots, detectors_json, seed=None))] - fn sample_batch_with_detectors( - &self, - num_shots: usize, - detectors_json: &str, - seed: Option, - ) -> PyResult, Vec)>> { - use pecos_random::PecosRng; - use rand::RngExt; - - let detector_records = parse_detector_records(detectors_json)?; + /// Number of logical errors. + #[pyo3(signature = (dem))] + fn decode_count_batch(&self, dem: &str) -> PyResult { + use pecos_decoders::{BatchConfig, PyMatchingDecoder}; + + let mut decoder = PyMatchingDecoder::from_dem(dem) + .map_err(|e| PyErr::new::(e.to_string()))?; + + let num_detectors = decoder.num_detectors(); + + // Flatten all detection events into a single contiguous array + let mut flat = Vec::with_capacity(self.num_shots * num_detectors); + let mut syndrome = vec![0u8; self.num_detectors]; + for i in 0..self.num_shots { + self.extract_syndrome(i, &mut syndrome); + // Pad or truncate to decoder's num_detectors + let take = syndrome.len().min(num_detectors); + flat.extend_from_slice(&syndrome[..take]); + flat.extend(std::iter::repeat_n(0, num_detectors - take)); + } - let mut rng = match seed { - Some(s) => PecosRng::seed_from_u64(s), - None => PecosRng::seed_from_u64(rand::rng().random()), + let config = BatchConfig { + bit_packed_input: false, + bit_packed_output: false, + return_weights: false, }; - let results: Vec<_> = (0..num_shots) - .map(|_| { - self.inner - .sample_with_detectors(&detector_records, &mut rng) - }) - .collect(); + let result = decoder + .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 + let num_observables = decoder.num_observables(); + let mut num_errors = 0usize; + for (i, prediction) in result.predictions.iter().enumerate() { + let mut predicted_mask = 0u64; + for (j, &v) in prediction.iter().enumerate() { + if v != 0 && j < num_observables { + predicted_mask |= 1 << j; + } + } + if predicted_mask != self.extract_obs_mask(i) { + num_errors += 1; + } + } - Ok(results) + Ok(num_errors) } - /// Sample for threshold estimation with both detection events and observable flips. + /// Decode all samples and collect per-shot timing statistics. /// - /// This matches Stim's DEM sampler output format, returning the information - /// needed for decoding and logical error rate computation. + /// Returns a `DecodeStats` with error count, total time, median, and + /// percentile per-shot decode times. Useful for understanding decoder + /// performance characteristics (heavy tails, etc.). /// /// Args: - /// `detectors_json`: JSON string with detector definitions. - /// `observables_json`: JSON string with observable definitions. - /// seed: Optional random seed for reproducibility. + /// dem: DEM string for the decoder. + /// `decoder_type`: Decoder type string. /// /// Returns: - /// Tuple of (`detection_events`, `observable_flips`). - #[pyo3(signature = (detectors_json, observables_json, seed=None))] - fn sample_for_decoding( - &self, - detectors_json: &str, - observables_json: &str, - seed: Option, - ) -> PyResult<(Vec, Vec)> { - use pecos_random::PecosRng; - use rand::RngExt; - - let detector_records = parse_detector_records(detectors_json)?; - let observable_records = parse_observable_records(observables_json)?; - - let mut rng = match seed { - Some(s) => PecosRng::seed_from_u64(s), - None => PecosRng::seed_from_u64(rand::rng().random()), - }; + /// `DecodeStats` with timing breakdown. + #[pyo3(signature = (dem, decoder_type="pymatching"))] + fn decode_stats(&self, dem: &str, decoder_type: &str) -> PyResult { + use std::time::Instant; + + 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); + let mut syndrome = vec![0u8; self.num_detectors]; + + 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 elapsed = t0.elapsed().as_secs_f64(); + per_shot_seconds.push(elapsed); + if predicted != self.extract_obs_mask(i) { + num_errors += 1; + } + } - let (detection_events, observable_flips) = - self.inner - .sample_for_decoding(&detector_records, &observable_records, &mut rng); - Ok((detection_events, observable_flips)) + Ok(PyDecodeStats::from_times( + self.num_shots, + num_errors, + per_shot_seconds, + )) } - /// Batch sample for threshold estimation. + /// Decode all shots with per-shot timing, using parallel workers. /// - /// Efficiently samples multiple shots, returning detection events and observable - /// flips for each shot. This is the PECOS native alternative to Stim's DEM sampler. + /// Like `decode_stats` but distributes shots across rayon threads. + /// Useful for slow decoders (MWPF, Tesseract, BP+OSD) where a single + /// shot can take seconds. /// - /// Args: - /// `num_shots`: Number of shots to sample. - /// `detectors_json`: JSON string with detector definitions. - /// `observables_json`: JSON string with observable definitions. - /// seed: Optional random seed for reproducibility. + /// Per-shot timing is still collected (each worker times its own shots). + /// The total wall-clock time is approximately `serial_total / num_workers`. /// - /// Returns: - /// Tuple of (`detection_events_per_shot`, `observable_flips_per_shot`) as numpy-compatible lists. - #[pyo3(signature = (num_shots, detectors_json, observables_json, seed=None))] - fn sample_batch_for_decoding( + /// Args: + /// dem: DEM string for the decoder. + /// `decoder_type`: Decoder type string. + /// `num_workers`: Number of parallel workers (default: number of CPUs). + #[pyo3(signature = (dem, decoder_type="mwpf", num_workers=None))] + fn decode_stats_parallel( &self, - num_shots: usize, - detectors_json: &str, - observables_json: &str, - seed: Option, - ) -> PyResult { - use pecos_random::PecosRng; - use rand::RngExt; + dem: &str, + decoder_type: &str, + num_workers: Option, + ) -> PyResult { + use rayon::prelude::*; + + let n_workers = num_workers.unwrap_or_else(rayon::current_num_threads); + + // Validate decoder type early. + create_observable_decoder(dem, decoder_type)?; + + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(n_workers) + .build() + .map_err(|e| PyErr::new::(e.to_string()))?; + + let dem_str = dem.to_string(); + let dt = decoder_type.to_string(); + let num_dets = self.num_detectors; + + // Materialize row-major data for parallel decode. + let detection_events: Vec> = (0..self.num_shots) + .map(|i| { + let mut s = vec![0u8; num_dets]; + self.extract_syndrome(i, &mut s); + s + }) + .collect(); + let observable_masks: Vec = (0..self.num_shots) + .map(|i| self.extract_obs_mask(i)) + .collect(); - let detector_records = parse_detector_records(detectors_json)?; - let observable_records = parse_observable_records(observables_json)?; + // Each worker decodes a slice of shots and returns (errors, per_shot_times). + let results: Vec<(usize, Vec)> = pool.install(|| { + let chunk_size = self.num_shots.div_ceil(n_workers); + (0..n_workers) + .into_par_iter() + .map(|worker_id| { + let start = worker_id * chunk_size; + let end = (start + chunk_size).min(self.num_shots); + if start >= end { + return (0, Vec::new()); + } - let mut rng = match seed { - Some(s) => PecosRng::seed_from_u64(s), - None => PecosRng::seed_from_u64(rand::rng().random()), - }; + let mut decoder = create_observable_decoder(&dem_str, &dt).unwrap(); + let mut errors = 0usize; + let mut times = Vec::with_capacity(end - start); + + for i in start..end { + let t0 = std::time::Instant::now(); + let predicted = decoder + .decode_to_observables(&detection_events[i]) + .unwrap_or(u64::MAX); + times.push(t0.elapsed().as_secs_f64()); + if predicted != observable_masks[i] { + errors += 1; + } + } + (errors, times) + }) + .collect() + }); - let (all_detection_events, all_observable_flips) = self.inner.sample_batch_for_decoding( - num_shots, - &detector_records, - &observable_records, - &mut rng, - ); + let mut total_errors = 0usize; + let mut all_times = Vec::with_capacity(self.num_shots); + for (errs, times) in results { + total_errors += errs; + all_times.extend(times); + } - Ok((all_detection_events, all_observable_flips)) + Ok(PyDecodeStats::from_times( + self.num_shots, + total_errors, + all_times, + )) } fn __repr__(&self) -> String { - format!( - "MeasurementNoiseModel(mechanisms={}, measurements={})", - self.num_mechanisms(), - self.num_measurements() - ) + format!("SampleBatch(num_shots={})", self.num_shots) } } -// ============================================================================= -// Noisy Sampler (DEM-style sampling) -// ============================================================================= - -/// Fast noisy sampler for threshold estimation. -/// -/// This is essentially a DEM sampler - it samples fault locations and uses -/// the influence map to determine detector and logical effects. This is the -/// recommended approach for threshold estimation as it directly samples -/// detector flips and observable flips without intermediate steps. -/// -/// Two modes are supported: -/// - Uniform noise: Same error probability at all locations (fast) -/// - Circuit-level noise: Per-gate-type probabilities (p1, p2, `p_meas`, `p_init`) -/// -/// # Example -/// -/// ```python -/// from pecos_rslib.qec import DagFaultAnalyzer, NoisySampler -/// -/// # Build influence map -/// analyzer = DagFaultAnalyzer(dag) -/// influence_map = analyzer.build_influence_map() -/// -/// # Uniform noise (simple) -/// sampler = NoisySampler(influence_map, p_error=0.01, seed=42) -/// -/// # Circuit-level noise (accurate) -/// sampler = NoisySampler.with_circuit_noise( -/// influence_map, p1=0.001, p2=0.01, p_meas=0.01, p_init=0.001, seed=42 -/// ) -/// -/// # Sample for threshold estimation -/// det_events, obs_flips = sampler.sample_batch(num_shots=10000) -/// ``` -#[pyclass(name = "NoisySampler", module = "pecos_rslib.qec")] -pub struct PyNoisySampler { - /// Owned influence map (cloned from input). - influence_map: RustDagFaultInfluenceMap, - /// Per-location error probabilities (for circuit-level noise). - per_location_probs: Vec, - /// RNG seed. - seed: u64, +/// Per-shot decode timing statistics. +#[pyclass(name = "DecodeStats", module = "pecos_rslib.qec", skip_from_py_object)] +#[derive(Clone)] +pub struct PyDecodeStats { + #[pyo3(get)] + pub num_shots: usize, + #[pyo3(get)] + pub num_errors: usize, + #[pyo3(get)] + pub logical_error_rate: f64, + #[pyo3(get)] + pub total_seconds: f64, + #[pyo3(get)] + pub per_shot_mean: f64, + #[pyo3(get)] + pub per_shot_median: f64, + #[pyo3(get)] + pub per_shot_p99: f64, + #[pyo3(get)] + pub per_shot_min: f64, + #[pyo3(get)] + pub per_shot_max: f64, + /// Quantile summary for distribution visualization (violin plots). + /// 21 values at percentiles [0, 5, 10, 15, ..., 90, 95, 100]. + #[pyo3(get)] + pub quantiles: Vec, } -#[pymethods] -impl PyNoisySampler { - /// Create a new noisy sampler with uniform error probability. - /// - /// Args: - /// `influence_map`: A `DagFaultInfluenceMap` from `DagFaultAnalyzer` or `InfluenceBuilder`. - /// `p_error`: Uniform depolarizing error probability per fault location. - /// seed: Random seed for reproducibility. - #[new] - #[pyo3(signature = (influence_map, p_error, seed=None))] - fn new(influence_map: &PyDagFaultInfluenceMap, p_error: f64, seed: Option) -> Self { - use rand::RngExt; - let actual_seed = seed.unwrap_or_else(|| rand::rng().random()); - let num_locations = influence_map.inner.locations.len(); - Self { - influence_map: influence_map.inner.clone(), - per_location_probs: vec![p_error; num_locations], - seed: actual_seed, - } - } +impl PyDecodeStats { + // Shot counts and error counts are well within f64 mantissa range (2^52). + // Percentile index computation is bounded by array length. + #[allow( + clippy::cast_precision_loss, + clippy::cast_possible_truncation, + clippy::cast_sign_loss + )] + fn from_times(num_shots: usize, num_errors: usize, mut times: Vec) -> Self { + let total_seconds: f64 = times.iter().sum(); + let per_shot_mean = if num_shots > 0 { + total_seconds / num_shots as f64 + } else { + 0.0 + }; - /// Create a sampler with circuit-level noise (different rates per gate type). - /// - /// This matches the noise model used by `DemBuilder` and Stim, with different - /// error probabilities for different gate types. - /// - /// Args: - /// `influence_map`: A `DagFaultInfluenceMap` from `DagFaultAnalyzer` or `InfluenceBuilder`. - /// p1: Single-qubit gate error probability. - /// p2: Two-qubit gate error probability. - /// `p_meas`: Measurement error probability. - /// `p_init`: Initialization/prep error probability. - /// seed: Random seed for reproducibility. - #[staticmethod] - #[pyo3(signature = (influence_map, p1, p2, p_meas, p_init, seed=None))] - fn with_circuit_noise( - influence_map: &PyDagFaultInfluenceMap, - p1: f64, - p2: f64, - p_meas: f64, - p_init: f64, - seed: Option, - ) -> Self { - use pecos_core::gate_type::GateType; - use rand::RngExt; + times.sort_by(|a, b| a.partial_cmp(b).unwrap_or(std::cmp::Ordering::Equal)); - let actual_seed = seed.unwrap_or_else(|| rand::rng().random()); + let percentile = |p: f64| -> f64 { + if times.is_empty() { + return 0.0; + } + let idx = (p / 100.0 * (times.len() - 1) as f64).round() as usize; + times[idx.min(times.len() - 1)] + }; - // Build per-location probabilities based on gate type - let per_location_probs: Vec = influence_map - .inner - .locations - .iter() - .map(|loc| { - #[allow(clippy::match_same_arms)] // Explicitly list known single-qubit gates - match loc.gate_type { - GateType::PZ | GateType::QAlloc => p_init, - GateType::MZ | GateType::MeasureFree => p_meas, - GateType::CX | GateType::CZ | GateType::CY | GateType::SWAP => p2, - GateType::H - | GateType::SZ - | GateType::SZdg - | GateType::SX - | GateType::SXdg - | GateType::SY - | GateType::SYdg - | GateType::X - | GateType::Y - | GateType::Z - | GateType::T - | GateType::Tdg => p1, - _ => p1, // Default to p1 for unknown gates - } - }) - .collect(); + // 21 quantiles at [0, 5, 10, ..., 95, 100] for violin plots + let quantiles: Vec = (0..=20).map(|i| percentile(f64::from(i) * 5.0)).collect(); Self { - influence_map: influence_map.inner.clone(), - per_location_probs, - seed: actual_seed, + num_shots, + num_errors, + logical_error_rate: if num_shots > 0 { + num_errors as f64 / num_shots as f64 + } else { + 0.0 + }, + total_seconds, + per_shot_mean, + per_shot_median: percentile(50.0), + per_shot_p99: percentile(99.0), + per_shot_min: times.first().copied().unwrap_or(0.0), + per_shot_max: times.last().copied().unwrap_or(0.0), + quantiles, } } +} - /// Sample a single shot. - /// - /// Returns: - /// Tuple of (`detector_flips`, `logical_flips`) where each is a list of - /// indices that flipped. - fn sample_one(&mut self) -> (Vec, Vec) { - use pecos_qec::fault_tolerance::noisy_sampler::{NoisySampler, PerLocationNoiseModel}; - - let noise_model = PerLocationNoiseModel::new(self.per_location_probs.clone()); - let mut sampler = NoisySampler::new(&self.influence_map, noise_model, self.seed); - let result = sampler.sample_one(); - // Update seed for next call - self.seed = self.seed.wrapping_add(1); - (result.detector_flips, result.logical_flips) +#[pymethods] +impl PyDecodeStats { + fn __repr__(&self) -> String { + format!( + "DecodeStats(shots={}, errors={}, LER={:.4}, median={:.2e}s, p99={:.2e}s, max={:.2e}s)", + self.num_shots, + self.num_errors, + self.logical_error_rate, + self.per_shot_median, + self.per_shot_p99, + self.per_shot_max, + ) } +} + +#[pyclass(name = "DemSampler", module = "pecos_rslib.qec")] +pub struct PyDemSampler { + inner: RustNewDemSampler, +} - /// Sample multiple shots and return as arrays suitable for decoding. +#[pymethods] +impl PyDemSampler { + /// Build a sampler directly from a circuit and noise parameters. /// - /// This is the main method for threshold estimation. Returns detection - /// events and observable flips in the same format as Stim's DEM sampler. + /// This is the simplest path: builds the influence map, extracts + /// annotations, and configures the sampler in one step. /// /// Args: - /// `num_shots`: Number of shots to sample. + /// circuit: A `DagCircuit` with gates and annotations. + /// p1: Single-qubit depolarizing error rate. + /// p2: Two-qubit depolarizing error rate. + /// `p_meas`: Measurement error rate. + /// `p_prep`: Initialization error rate. + /// `p_idle`: Optional idle noise rate per time unit. /// - /// Returns: - /// Tuple of (`detection_events`, `observable_flips`) where: - /// - `detection_events`: List of lists, each inner list contains bool per detector - /// - `observable_flips`: List of lists, each inner list contains bool per observable - fn sample_batch(&mut self, num_shots: usize) -> (Vec>, Vec>) { - use pecos_qec::fault_tolerance::noisy_sampler::{NoisySampler, PerLocationNoiseModel}; - - let noise_model = PerLocationNoiseModel::new(self.per_location_probs.clone()); - let mut sampler = NoisySampler::new(&self.influence_map, noise_model, self.seed); - let num_detectors = self.influence_map.detectors.len(); - let num_logicals = self - .influence_map - .influences - .max_logical_index() - .map_or(1, |i| i + 1); - - let mut all_det_events = Vec::with_capacity(num_shots); - let mut all_obs_flips = Vec::with_capacity(num_shots); - - for _ in 0..num_shots { - let result = sampler.sample_one(); - - // Convert sparse detector flips to dense bool array - let mut det_events = vec![false; num_detectors]; - for &idx in &result.detector_flips { - if (idx as usize) < num_detectors { - det_events[idx as usize] = true; - } - } - - // Convert sparse logical flips to dense bool array - let mut obs_flips = vec![false; num_logicals]; - for &idx in &result.logical_flips { - if (idx as usize) < num_logicals { - obs_flips[idx as usize] = true; - } - } - - all_det_events.push(det_events); - all_obs_flips.push(obs_flips); + /// Example: + /// >>> 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))] + fn from_circuit( + circuit: &Bound<'_, pyo3::PyAny>, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + p_idle: Option, + idle_rz: 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); } - // Update seed for reproducibility of subsequent calls - self.seed = self.seed.wrapping_add(num_shots as u64); - - (all_det_events, all_obs_flips) - } - - /// Sample and compute statistics directly in Rust. - /// - /// This is more efficient than sampling and processing in Python - /// when you only need aggregate statistics. - /// - /// Args: - /// `num_shots`: Number of shots to sample. - /// - /// Returns: - /// Dictionary with statistics: - /// - `total_shots`: Number of shots - /// - `logical_error_count`: Shots with logical errors - /// - `syndrome_count`: Shots with non-trivial syndrome - /// - `undetectable_count`: Shots with undetectable logical errors - /// - `logical_error_rate`: Fraction with logical errors - /// - `syndrome_rate`: Fraction with syndromes - fn sample_statistics( - &mut self, - num_shots: usize, - py: Python<'_>, - ) -> PyResult> { - use pecos_qec::fault_tolerance::noisy_sampler::{NoisySampler, PerLocationNoiseModel}; - - let noise_model = PerLocationNoiseModel::new(self.per_location_probs.clone()); - let mut sampler = NoisySampler::new(&self.influence_map, noise_model, self.seed); - let stats = sampler.sample_statistics(num_shots); - - // Update seed - self.seed = self.seed.wrapping_add(num_shots as u64); - - let dict = pyo3::types::PyDict::new(py); - dict.set_item("total_shots", stats.total_shots)?; - dict.set_item("logical_error_count", stats.logical_error_count)?; - dict.set_item("syndrome_count", stats.syndrome_count)?; - dict.set_item("undetectable_count", stats.undetectable_count)?; - dict.set_item("logical_error_rate", stats.logical_error_rate())?; - dict.set_item("syndrome_rate", stats.syndrome_rate())?; - dict.set_item("undetectable_rate", stats.undetectable_rate())?; - dict.set_item("average_faults", stats.average_faults())?; - Ok(dict.unbind()) - } - - /// Number of fault locations. - #[getter] - fn num_locations(&self) -> usize { - self.influence_map.locations.len() - } - - /// Number of detectors. - #[getter] - fn num_detectors(&self) -> usize { - self.influence_map.detectors.len() - } - - /// Number of logical observables. - #[getter] - fn num_logicals(&self) -> usize { - self.influence_map - .influences - .max_logical_index() - .map_or(1, |i| i + 1) + // Accept both DagCircuit and TickCircuit + if let Ok(dag) = + circuit.extract::>() + { + let inner = RustNewDemSampler::from_circuit(&dag.inner, &noise) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(Self { inner }) + } else if let Ok(tc) = + circuit.extract::>() + { + let inner = RustNewDemSampler::from_tick_circuit(&tc.inner, &noise) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(Self { inner }) + } else { + Err(pyo3::exceptions::PyTypeError::new_err( + "from_circuit() expects a DagCircuit or TickCircuit", + )) + } } - /// Sample with explicit detector and observable definitions. - /// - /// This combines `NoisySampler`'s fast per-location sampling with explicit - /// detector/observable definitions (like MNM uses). This gives the best of - /// both worlds: fast Rust-side sampling with Stim-compatible output. + /// Create a sampler from a standard DEM-format string. /// - /// Args: - /// `num_shots`: Number of shots to sample. - /// `detectors_json`: JSON string with detector definitions. - /// `observables_json`: JSON string with observable definitions. - /// `measurement_order`: Optional list of qubit indices in `TickCircuit` measurement - /// execution order. Required when detector definitions use `TickCircuit` - /// measurement indices but the influence map uses a different ordering. - /// measurement_order[i] is the qubit measured at `TickCircuit` index i. + /// Parses `error(p) D0 D3 L0` lines and builds a sampling engine. + /// Useful for sampling from DEMs produced by EEG analysis. /// - /// Returns: - /// Tuple of (`detection_events`, `observable_flips`) matching Stim's format. - #[pyo3(signature = (num_shots, detectors_json, observables_json, measurement_order=None))] - fn sample_with_definitions( - &mut self, - num_shots: usize, - detectors_json: &str, - observables_json: &str, - measurement_order: Option>, - ) -> PyResult { - use pecos_qec::fault_tolerance::noisy_sampler::{NoisySampler, PerLocationNoiseModel}; - use std::collections::HashMap; - - let detector_records = parse_detector_records(detectors_json)?; - let observable_records = parse_observable_records(observables_json)?; - - let noise_model = PerLocationNoiseModel::new(self.per_location_probs.clone()); - let mut sampler = NoisySampler::new(&self.influence_map, noise_model, self.seed); - let num_im_measurements = self.influence_map.detectors.len(); - - // Build mapping from influence map indices to TickCircuit indices if measurement_order provided - let im_to_tc: Option> = measurement_order.as_ref().map(|tc_order| { - // Build (qubit, occurrence) -> TC index mapping - let mut qubit_occurrences: HashMap> = HashMap::new(); - for (tc_idx, &qubit) in tc_order.iter().enumerate() { - qubit_occurrences.entry(qubit).or_default().push(tc_idx); + /// Example: + /// >>> from pecos_rslib_exp import eeg_heisenberg_dem + /// >>> dem_str = eeg_heisenberg_dem(tc, idle_rz=0.05) + /// >>> sampler = DemSampler.from_dem_string(dem_str) + /// >>> results = sampler.sample_batch(shots=1000000) + #[staticmethod] + #[pyo3(signature = (dem_string))] + fn from_dem_string(dem_string: &str) -> PyResult { + use pecos_qec::fault_tolerance::dem_builder::SamplingEngine; + + let mut mechanisms = Vec::new(); + let mut max_det = 0u32; + let mut max_obs = 0u32; + + for line in dem_string.lines() { + let line = line.trim(); + if line.is_empty() || line.starts_with('#') { + continue; } - // Track how many times we've seen each qubit in the IM - let mut qubit_seen_count: HashMap = HashMap::new(); - - // For each IM measurement, find corresponding TC index - self.influence_map - .measurements - .iter() - .map(|&(_node, qubit, _basis)| { - let occurrence = *qubit_seen_count.entry(qubit).or_insert(0); - qubit_seen_count.insert(qubit, occurrence + 1); - - // Get the TC index for this qubit's nth occurrence - qubit_occurrences - .get(&qubit) - .and_then(|indices| indices.get(occurrence).copied()) - .unwrap_or(usize::MAX) - }) - .collect() - }); - - let num_tc_measurements = measurement_order - .as_ref() - .map_or(num_im_measurements, std::vec::Vec::len); - - let mut all_det_events = Vec::with_capacity(num_shots); - let mut all_obs_flips = Vec::with_capacity(num_shots); - - for _ in 0..num_shots { - let result = sampler.sample_one(); - - // Convert sparse IM measurement flips to dense TC measurement array - let mut meas_outcomes = vec![false; num_tc_measurements]; - - if let Some(ref mapping) = im_to_tc { - // Reorder from IM order to TC order - for &im_idx in &result.detector_flips { - let im_idx = im_idx as usize; - if im_idx < mapping.len() { - let tc_idx = mapping[im_idx]; - if tc_idx < num_tc_measurements { - meas_outcomes[tc_idx] = !meas_outcomes[tc_idx]; - } - } - } - } else { - // No reordering needed - for &idx in &result.detector_flips { - if (idx as usize) < num_tc_measurements { - meas_outcomes[idx as usize] = true; - } + // Parse: error(prob) D0 D3 L0 + let Some(rest) = line.strip_prefix("error(") else { + continue; + }; + let Some(paren_end) = rest.find(')') else { + continue; + }; + let prob: f64 = rest[..paren_end].parse().map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("bad probability: {e}")) + })?; + let tokens = rest[paren_end + 1..].split_whitespace(); + let mut dets = Vec::new(); + let mut obs = Vec::new(); + for tok in tokens { + if let Some(d) = tok.strip_prefix('D') { + let id: u32 = d.parse().map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("bad detector: {e}")) + })?; + dets.push(id); + max_det = max_det.max(id + 1); + } else if let Some(l) = tok.strip_prefix('L') { + let id: u32 = l.parse().map_err(|e| { + pyo3::exceptions::PyValueError::new_err(format!("bad observable: {e}")) + })?; + obs.push(id); + max_obs = max_obs.max(id + 1); } } - - // Apply detector definitions (XOR of measurement outcomes) - let det_events: Vec = detector_records - .iter() - .map(|records| { - let mut fired = false; - for &offset in records { - if let Some(abs_idx) = - record_offset_to_absolute_index(num_tc_measurements, offset) - && abs_idx < num_tc_measurements - && meas_outcomes[abs_idx] - { - fired = !fired; - } - } - fired - }) - .collect(); - - // Apply observable definitions - let obs_flips: Vec = observable_records - .iter() - .map(|records| { - let mut flipped = false; - for &offset in records { - if let Some(abs_idx) = - record_offset_to_absolute_index(num_tc_measurements, offset) - && abs_idx < num_tc_measurements - && meas_outcomes[abs_idx] - { - flipped = !flipped; - } - } - flipped - }) - .collect(); - - all_det_events.push(det_events); - all_obs_flips.push(obs_flips); + if prob > 0.0 { + mechanisms.push((prob, dets, obs)); + } } - self.seed = self.seed.wrapping_add(num_shots as u64); - Ok((all_det_events, all_obs_flips)) + let engine = + SamplingEngine::from_mechanisms(mechanisms, max_det as usize, max_obs as usize); + let inner = RustNewDemSampler::from_engine(engine); + Ok(Self { inner }) } - fn __repr__(&self) -> String { - format!( - "NoisySampler(locations={}, detectors={}, logicals={})", - self.num_locations(), - self.num_detectors(), - self.num_logicals(), - ) + /// Create a sampler in raw measurement mode with uniform noise. + #[staticmethod] + #[pyo3(signature = (influence_map, p_error))] + fn raw_uniform(influence_map: &PyDagFaultInfluenceMap, p_error: f64) -> PyResult { + Self::from_influence_map(influence_map, p_error) } -} - -// ============================================================================= -// MNM Builder -// ============================================================================= - -/// Builder for Measurement Noise Models (MNMs). -/// -/// Constructs a MNM from a fault influence map. The MNM aggregates fault locations -/// by their measurement effects (which measurements flip), enabling fast approximate -/// sampling. -/// -/// # Comparison with DEM -/// -/// | Aspect | DEM | MNM | -/// |--------|-----|-----| -/// | Maps to | Detectors | Measurements | -/// | Use case | Decoding | Sampling | -/// | Aggregates by | Detector signature | Measurement signature | -/// | Output | Stim-compatible DEM | Raw measurement outcomes | -/// -/// # Example -/// -/// ```python -/// from pecos_rslib.qec import DagFaultAnalyzer, MemBuilder -/// -/// # Build influence map -/// analyzer = DagFaultAnalyzer(dag) -/// influence_map = analyzer.build_influence_map() -/// -/// # Build MNM for fast sampling -/// mnm = MemBuilder(influence_map).with_noise(0.01, 0.01, 0.01, 0.01).build() -/// -/// # Sample many shots quickly -/// for _ in range(10000): -/// outcomes = mnm.sample() -/// ``` -#[pyclass(name = "MemBuilder", module = "pecos_rslib.qec")] -pub struct PyMemBuilder { - influence_map: RustDagFaultInfluenceMap, - p1: f64, - p2: f64, - p_meas: f64, - p_init: f64, - /// Measurement order from original circuit (list of qubits in measurement order). - measurement_order: Option>, -} -#[pymethods] -impl PyMemBuilder { - /// Create a new MNM builder from a fault influence map. - /// - /// Args: - /// `influence_map`: A `DagFaultInfluenceMap` from `DagFaultAnalyzer`. - #[new] - fn new(influence_map: &PyDagFaultInfluenceMap) -> Self { - Self { - influence_map: influence_map.inner.clone(), - p1: 0.01, - p2: 0.01, - p_meas: 0.01, - p_init: 0.01, - measurement_order: None, - } + /// Create a sampler in raw measurement mode with circuit-level noise. + #[staticmethod] + #[pyo3(signature = (influence_map, p1, p2, p_meas, p_prep))] + fn raw( + influence_map: &PyDagFaultInfluenceMap, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + ) -> PyResult { + Self::from_influence_map_circuit_noise(influence_map, p1, p2, p_meas, p_prep) } - /// Set the noise parameters. - /// - /// Args: - /// p1: Single-qubit depolarizing error rate. - /// p2: Two-qubit depolarizing error rate. - /// `p_meas`: Measurement error rate. - /// `p_init`: Initialization (prep) error rate. + /// Create a sampler in detector-event mode. /// - /// Returns: - /// Self for method chaining. - fn with_noise( - mut slf: PyRefMut<'_, Self>, + /// 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))] + #[allow(clippy::too_many_arguments)] + fn with_detectors( + influence_map: &PyDagFaultInfluenceMap, + detectors: Vec>, + observables: Vec>, p1: f64, p2: f64, p_meas: f64, - p_init: f64, - ) -> PyRefMut<'_, Self> { - slf.p1 = p1; - slf.p2 = p2; - slf.p_meas = p_meas; - slf.p_init = p_init; - slf + p_prep: f64, + p_idle: Option, + t1: Option, + t2: 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 inner = RustNewDemSamplerBuilder::new(&influence_map.inner) + .with_noise_config(noise) + .with_detectors(detectors, observables) + .build() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(Self { inner }) } - /// Set the measurement order from the original circuit (e.g., `TickCircuit`). - /// - /// This is needed when detector definitions use `TickCircuit` measurement indices - /// but the influence map uses a different ordering based on DAG topology. + /// Create a sampler directly from an influence map with uniform noise. /// /// Args: - /// order: List of qubit indices in measurement execution order. - /// order[i] is the qubit measured at `TickCircuit` measurement index i. - /// - /// Returns: - /// Self for method chaining. - fn with_measurement_order( - mut slf: PyRefMut<'_, Self>, - order: Vec, - ) -> PyRefMut<'_, Self> { - slf.measurement_order = Some(order); - slf - } - - /// Build the Measurement Noise Model. - /// - /// This aggregates all fault locations by their measurement effects. - /// Locations that produce the same measurement signature have their - /// probabilities combined using the independent error formula. - /// - /// Returns: - /// A `MeasurementNoiseModel` for fast approximate sampling. - fn build(&self) -> PyMeasurementNoiseModel { - let mut builder = RustMemBuilder::new(&self.influence_map).with_noise( - self.p1, - self.p2, - self.p_meas, - self.p_init, - ); - - if let Some(ref order) = self.measurement_order { - builder = builder.with_measurement_order(order.clone()); - } - - let inner = builder.build(); - PyMeasurementNoiseModel { inner } + /// `influence_map`: A `DagFaultInfluenceMap` from `DagFaultAnalyzer` or `InfluenceBuilder`. + /// `p_error`: Uniform depolarizing error probability per fault location. + #[staticmethod] + fn from_influence_map(influence_map: &PyDagFaultInfluenceMap, p_error: f64) -> PyResult { + let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) + .with_uniform_noise(p_error) + .raw_measurements() + .build() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(Self { inner }) } - fn __repr__(&self) -> String { - format!( - "MemBuilder(p1={}, p2={}, p_meas={}, p_init={})", - self.p1, self.p2, self.p_meas, self.p_init - ) + /// Create a sampler from an influence map with circuit-level noise. + #[staticmethod] + fn from_influence_map_circuit_noise( + influence_map: &PyDagFaultInfluenceMap, + p1: f64, + p2: f64, + p_meas: f64, + p_prep: f64, + ) -> PyResult { + let inner = RustNewDemSamplerBuilder::new(&influence_map.inner) + .with_noise(p1, p2, p_meas, p_prep) + .raw_measurements() + .build() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; + Ok(Self { inner }) } -} - -// ============================================================================= -// DEM Sampler (Fast DEM-style sampling) -// ============================================================================= - -/// Fast DEM-style sampler for threshold estimation. -/// -/// This sampler aggregates fault effects directly into detector/observable signatures, -/// matching Stim's DEM sampler semantics. It uses data-oriented design for optimal -/// cache performance: -/// -/// - Precomputed u64 thresholds (no f64 comparison during sampling) -/// - CSR layout for detector/observable indices -/// - Bit-packed outcomes for compact storage and fast XOR -/// -/// # Example -/// -/// ```python -/// from pecos_rslib.qec import DagFaultAnalyzer, DemSamplerBuilder -/// -/// # Build influence map -/// analyzer = DagFaultAnalyzer(dag) -/// influence_map = analyzer.build_influence_map() -/// -/// # Build sampler with explicit detector/observable definitions -/// # `detectors_json` may use either `id` or `detector_id`. -/// # `observables_json` may use either `id` or `observable_id`. -/// sampler = DemSamplerBuilder(influence_map) \ -/// .with_noise(0.01, 0.01, 0.01, 0.01) \ -/// .with_detectors_json(detectors_json) \ -/// .with_observables_json(observables_json) \ -/// .with_measurement_order(measurement_order) \ -/// .build() -/// -/// # Fast batch sampling for threshold estimation -/// det_events, obs_flips = sampler.sample_batch(10000) -/// ``` -#[pyclass(name = "DemSampler", module = "pecos_rslib.qec")] -pub struct PyDemSampler { - inner: pecos_qec::fault_tolerance::dem_builder::DemSampler, -} -#[pymethods] -impl PyDemSampler { /// Number of mechanisms in the sampler. #[getter] fn num_mechanisms(&self) -> usize { self.inner.num_mechanisms() } - /// Number of detectors. + /// Number of output channels (detectors or measurements). + #[getter] + fn num_outputs(&self) -> usize { + self.inner.num_outputs() + } + + /// Number of detectors (alias for `num_outputs`). #[getter] fn num_detectors(&self) -> usize { - self.inner.num_detectors() + self.inner.num_outputs() } - /// Number of observables. + /// Number of observables when sampler metadata is known. #[getter] fn num_observables(&self) -> usize { self.inner.num_observables() } + /// Total number of outputs in the DEM `L` namespace. + #[getter] + fn num_dem_outputs(&self) -> usize { + self.inner.num_dem_outputs() + } + + /// Number of tracked Paulis. + #[getter] + fn num_tracked_paulis(&self) -> usize { + self.inner.num_tracked_paulis() + } + /// Sample a single shot. /// /// Args: /// seed: Optional random seed for reproducibility. /// /// Returns: - /// Tuple of (`detection_events`, `observable_flips`) as boolean lists. + /// Tuple of (`detection_events`, `dem_output_flips`) as boolean lists. #[pyo3(signature = (seed=None))] fn sample(&self, seed: Option) -> (Vec, Vec) { use pecos_random::PecosRng; @@ -2162,7 +3367,7 @@ impl PyDemSampler { /// seed: Optional random seed for reproducibility. /// /// Returns: - /// Tuple of (`all_detection_events`, `all_observable_flips`). + /// Tuple of (`all_detection_events`, `all_dem_output_flips`). #[pyo3(signature = (num_shots, seed=None))] fn sample_batch( &self, @@ -2180,22 +3385,93 @@ impl PyDemSampler { self.inner.sample_batch(num_shots, &mut rng) } - /// Compute statistics without storing individual shots. - /// - /// This is the most efficient method for threshold estimation when you - /// only need aggregate statistics (logical error rate, syndrome rate). + /// Sample direct tracked-Pauli flips. /// - /// Args: - /// `num_shots`: Number of shots to sample. - /// seed: Optional random seed for reproducibility. + /// Raises: + /// RuntimeError: If this sampler carries tracked Paulis but the + /// backend cannot evaluate tracked-Pauli flips directly. + #[pyo3(signature = (seed=None))] + fn sample_tracked_paulis(&self, seed: Option) -> PyResult> { + use pecos_random::PecosRng; + use rand::RngExt; + + let mut rng = match seed { + Some(s) => PecosRng::seed_from_u64(s), + None => PecosRng::seed_from_u64(rand::rng().random()), + }; + + self.inner + .sample_tracked_pauli_flips(&mut rng) + .map_err(|e| PyErr::new::(e.to_string())) + } + + /// Sample direct tracked-Pauli flips for multiple shots. /// - /// Returns: - /// Dictionary with statistics: - /// - `total_shots`: Number of shots - /// - `logical_error_count`: Shots with logical errors + /// Raises: + /// RuntimeError: If this sampler carries tracked Paulis but the + /// backend cannot evaluate tracked-Pauli flips directly. + #[pyo3(signature = (num_shots, seed=None))] + fn sample_tracked_pauli_batch( + &self, + num_shots: usize, + seed: Option, + ) -> PyResult>> { + use pecos_random::PecosRng; + use rand::RngExt; + + let mut rng = match seed { + Some(s) => PecosRng::seed_from_u64(s), + None => PecosRng::seed_from_u64(rand::rng().random()), + }; + + self.inner + .sample_tracked_pauli_batch(num_shots, &mut rng) + .map_err(|e| PyErr::new::(e.to_string())) + } + + /// Generate samples and store them in Rust memory as a `SampleBatch`. + /// + /// The batch can then be decoded by multiple decoders without re-sampling. + /// This is the proper way to compare decoders: same samples, different decoders. + /// + /// Args: + /// `num_shots`: Number of shots to sample. + /// seed: Optional random seed for reproducibility. + /// + /// Returns: + /// `SampleBatch` object with samples held in Rust memory. + #[pyo3(signature = (num_shots, seed=None))] + fn generate_samples(&self, num_shots: usize, seed: Option) -> PySampleBatch { + use pecos_random::PecosRng; + use rand::RngExt; + + let mut rng = match seed { + Some(s) => PecosRng::seed_from_u64(s), + None => PecosRng::seed_from_u64(rand::rng().random()), + }; + + // Use geometric columnar sampler via DemSampler. + let (det_columns, obs_columns) = self.inner.sample_batch_geometric(num_shots, &mut rng); + + PySampleBatch::from_columnar(det_columns, obs_columns, num_shots) + } + + /// Compute statistics without storing individual shots. + /// + /// This is the most efficient method for threshold estimation when you + /// only need aggregate statistics (logical error rate, syndrome rate). + /// + /// Args: + /// `num_shots`: Number of shots to sample. + /// seed: Optional random seed for reproducibility. + /// + /// Returns: + /// Dictionary with statistics: + /// - `total_shots`: Number of shots + /// - `logical_error_count`: Shots with selected observable flips /// - `syndrome_count`: Shots with non-trivial syndrome - /// - `undetectable_count`: Shots with undetectable logical errors - /// - `logical_error_rate`: Fraction with logical errors + /// - `undetectable_count`: Shots with observable flips and no syndrome + /// - `logical_error_rate`: Fraction with selected observable flips /// - `syndrome_rate`: Fraction with syndromes /// - `undetectable_rate`: Fraction with undetectable errors #[pyo3(signature = (num_shots, seed=None))] @@ -2209,6 +3485,24 @@ impl PyDemSampler { let actual_seed = seed.unwrap_or_else(|| rand::rng().random()); let stats = self.inner.sample_statistics(num_shots, actual_seed); + let observable_indices = self.inner.observable_ids(); + let tracked_pauli_result = self.inner.tracked_pauli_ids(); + let tracked_pauli_statistics_error = + tracked_pauli_result.as_ref().err().map(ToString::to_string); + let tracked_pauli_indices = tracked_pauli_result.unwrap_or_default(); + let per_observable = stats.observable_counts(&observable_indices); + let per_tracked_pauli: Vec = tracked_pauli_indices + .iter() + .filter_map(|&idx| stats.dem_output_counts().get(idx).copied()) + .collect(); + let logical_rates = stats.logical_rates(&observable_indices); + #[allow(clippy::cast_precision_loss)] // Counts are converted to rates for Python reporting. + let n = stats.total_shots as f64; + #[allow(clippy::cast_precision_loss)] // Counts are converted to rates for Python reporting. + let tracked_pauli_rates: Vec = per_tracked_pauli + .iter() + .map(|&count| count as f64 / n) + .collect(); let dict = pyo3::types::PyDict::new(py); dict.set_item("total_shots", stats.total_shots)?; @@ -2218,15 +3512,178 @@ impl PyDemSampler { dict.set_item("logical_error_rate", stats.logical_error_rate())?; dict.set_item("syndrome_rate", stats.syndrome_rate())?; dict.set_item("undetectable_rate", stats.undetectable_rate())?; + dict.set_item("per_detector", &stats.per_detector)?; + dict.set_item("per_observable", per_observable)?; + dict.set_item("per_tracked_pauli", per_tracked_pauli)?; + dict.set_item("per_dem_output", stats.dem_output_counts())?; + dict.set_item("detector_rates", stats.detector_rates())?; + dict.set_item("logical_rates", logical_rates)?; + dict.set_item("tracked_pauli_rates", tracked_pauli_rates)?; + dict.set_item("dem_output_rates", stats.dem_output_rates())?; + dict.set_item( + "tracked_pauli_statistics_supported", + tracked_pauli_statistics_error.is_none(), + )?; + if let Some(error) = tracked_pauli_statistics_error { + dict.set_item("tracked_pauli_statistics_error", error)?; + } + Ok(dict.unbind()) + } + + /// Get labels for the sampler's output channels. + /// + /// Returns a dict with: + /// - `outputs`: labels for output channels (raw measurements or detectors) + /// - `dem_outputs`: labels for all DEM `L` targets + /// - `observables`: labels for observables + /// - `tracked_paulis`: labels for tracked Paulis + /// - `dual_detectors`: labels for dual-output detector channels + fn labels(&self, py: Python<'_>) -> PyResult> { + let labels = self.inner.labels(); + let dict = pyo3::types::PyDict::new(py); + dict.set_item("outputs", &labels.outputs)?; + dict.set_item("dem_outputs", &labels.dem_output_labels)?; + dict.set_item("observables", &labels.dem_output_labels)?; + dict.set_item("tracked_paulis", &labels.tracked_pauli_labels)?; + dict.set_item("dual_detectors", &labels.dual_detectors)?; Ok(dict.unbind()) } + /// Sample and decode in a tight Rust loop, returning only the error count. + /// + /// This is the fastest path for threshold estimation -- no per-shot data + /// crosses the Rust/Python boundary. The sampler produces detection events, + /// the decoder decodes them via the `ObservableDecoder` trait, and errors + /// are counted, all in Rust. + /// + /// 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". + /// seed: Optional random seed for reproducibility. + /// + /// Returns: + /// Number of logical errors (mismatches between decoder prediction and true flip). + #[pyo3(signature = (dem, num_shots, decoder_type="pymatching", seed=None))] + fn sample_decode_count( + &self, + dem: &str, + num_shots: usize, + decoder_type: &str, + seed: Option, + ) -> PyResult { + use pecos_random::PecosRng; + use rand::RngExt; + + let actual_seed = seed.unwrap_or_else(|| rand::rng().random()); + 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(); + + // Tight sample+decode loop -- no Python involvement. + // Single-threaded: sample and decode sequentially. + let mut errors = 0usize; + 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 true_mask = self.inner.observable_mask_from_dem_output_flips(&obs_flips); + if (predicted_mask & observable_mask) != true_mask { + errors += 1; + } + } + Ok(errors) + } + + /// Parallel sample+decode: distributes shots across threads. + /// + /// Each thread gets its own sampler clone and decoder instance. + /// Much faster for slow decoders (Tesseract) where decode time dominates. + /// + /// 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`". + /// seed: Optional base random seed. Each thread gets seed + `thread_id`. + /// `num_workers`: Number of parallel workers (default: number of CPUs). + /// + /// Returns: + /// Number of logical errors. + #[pyo3(signature = (dem, num_shots, decoder_type="pymatching", seed=None, num_workers=None))] + fn sample_decode_count_parallel( + &self, + dem: &str, + num_shots: usize, + decoder_type: &str, + seed: Option, + num_workers: Option, + ) -> PyResult { + use rayon::prelude::*; + + let actual_seed = seed.unwrap_or(0); + let n_workers = num_workers.unwrap_or_else(rayon::current_num_threads); + + // Validate decoder type early + create_observable_decoder(dem, decoder_type)?; + + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(n_workers) + .build() + .map_err(|e| PyErr::new::(e.to_string()))?; + + let shots_per_worker = num_shots / n_workers; + let remainder = num_shots % n_workers; + + let sampler = &self.inner; + let observable_mask = sampler.observable_dem_output_mask(); + let dem_str = dem.to_string(); + let dt = decoder_type.to_string(); + + let total_errors: usize = pool.install(|| { + (0..n_workers) + .into_par_iter() + .map(|worker_id| { + use pecos_random::PecosRng; + + let my_shots = shots_per_worker + usize::from(worker_id < remainder); + if my_shots == 0 { + return 0; + } + + let my_sampler = sampler.clone(); + let mut my_rng = + PecosRng::seed_from_u64(actual_seed.wrapping_add(worker_id as u64)); + // unwrap is safe: we validated above + let mut decoder = create_observable_decoder(&dem_str, &dt).unwrap(); + + let mut errors = 0usize; + 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 truth = my_sampler.observable_mask_from_dem_output_flips(&obs_flips); + if (predicted & observable_mask) != truth { + errors += 1; + } + } + errors + }) + .sum() + }); + + Ok(total_errors) + } + fn __repr__(&self) -> String { format!( - "DemSampler(mechanisms={}, detectors={}, observables={})", + "DemSampler(mechanisms={}, outputs={}, dem_outputs={}, observables={}, tracked_paulis={})", self.num_mechanisms(), - self.num_detectors(), + self.num_outputs(), + self.num_dem_outputs(), self.num_observables(), + self.num_tracked_paulis(), ) } } @@ -2234,14 +3691,11 @@ impl PyDemSampler { /// Builder for `DemSampler`. /// /// Constructs a `DemSampler` from a fault influence map, noise parameters, -/// and explicit detector/observable definitions. +/// and explicit detector / observable definitions. #[pyclass(name = "DemSamplerBuilder", module = "pecos_rslib.qec")] pub struct PyDemSamplerBuilder { influence_map: RustDagFaultInfluenceMap, - p1: f64, - p2: f64, - p_meas: f64, - p_init: f64, + noise: NoiseConfig, detectors_json: Option, observables_json: Option, measurement_order: Option>, @@ -2254,10 +3708,7 @@ impl PyDemSamplerBuilder { fn new(influence_map: &PyDagFaultInfluenceMap) -> Self { Self { influence_map: influence_map.inner.clone(), - p1: 0.01, - p2: 0.01, - p_meas: 0.01, - p_init: 0.01, + noise: NoiseConfig::default(), detectors_json: None, observables_json: None, measurement_order: None, @@ -2265,17 +3716,28 @@ impl PyDemSamplerBuilder { } /// Set noise parameters. + #[pyo3(signature = (p1, p2, p_meas, p_prep, p_idle=None, t1=None, t2=None, idle_rz=None))] + #[allow(clippy::too_many_arguments)] fn with_noise( mut slf: PyRefMut<'_, Self>, p1: f64, p2: f64, p_meas: f64, - p_init: f64, + p_prep: f64, + p_idle: Option, + t1: Option, + t2: Option, + idle_rz: Option, ) -> PyRefMut<'_, Self> { - slf.p1 = p1; - slf.p2 = p2; - slf.p_meas = p_meas; - slf.p_init = p_init; + 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 } @@ -2290,8 +3752,8 @@ impl PyDemSamplerBuilder { /// Set observable definitions from JSON. /// - /// Accepts either legacy observable rows with an `"id"` key or public surface - /// descriptor rows with an `"observable_id"` key. + /// Tracked Paulis are carried by the influence map; this helper is for + /// observable metadata. fn with_observables_json(mut slf: PyRefMut<'_, Self>, json: String) -> PyRefMut<'_, Self> { slf.observables_json = Some(json); slf @@ -2308,14 +3770,8 @@ impl PyDemSamplerBuilder { /// Build the `DemSampler`. fn build(&self) -> PyResult { - use pecos_qec::fault_tolerance::dem_builder::DemSamplerBuilder; - - let mut builder = DemSamplerBuilder::new(&self.influence_map).with_noise( - self.p1, - self.p2, - self.p_meas, - self.p_init, - ); + let mut builder = RustNewDemSamplerBuilder::new(&self.influence_map) + .with_noise_config(self.noise.clone()); if let Some(ref json) = self.detectors_json { builder = builder @@ -2333,14 +3789,16 @@ impl PyDemSamplerBuilder { builder = builder.with_measurement_order(order.clone()); } - let inner = builder.build(); + let inner = builder + .build() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string()))?; Ok(PyDemSampler { inner }) } fn __repr__(&self) -> String { format!( - "DemSamplerBuilder(p1={}, p2={}, p_meas={}, p_init={})", - self.p1, self.p2, self.p_meas, self.p_init + "DemSamplerBuilder(p1={}, p2={}, p_meas={}, p_prep={}, p_idle={:?})", + self.noise.p1, self.noise.p2, self.noise.p_meas, self.noise.p_prep, self.noise.p_idle ) } } @@ -2476,7 +3934,7 @@ impl PyEquivalenceResult { /// A parsed Detector Error Model. /// -/// Parses DEM strings in Stim/PECOS format and provides methods for +/// Parses standard and PECOS DEM strings and provides methods for /// aggregation and sampling. /// /// # Example @@ -2498,7 +3956,7 @@ impl PyParsedDem { /// Parse a DEM from a string. /// /// Args: - /// `dem_str`: DEM string in Stim/PECOS format. + /// `dem_str`: DEM string in standard or PECOS DEM text format. /// /// Returns: /// `ParsedDem` object. @@ -2525,10 +3983,36 @@ impl PyParsedDem { self.inner.num_detectors } - /// Number of observables (max ID + 1). + /// Number of observables. #[getter] fn num_observables(&self) -> u32 { - self.inner.num_observables + self.inner.num_observables() + } + + /// Total number of outputs in the DEM `L` namespace. + #[getter] + fn num_dem_outputs(&self) -> u32 { + self.inner.num_dem_outputs() + } + + /// Number of tracked Paulis. + #[getter] + fn num_tracked_paulis(&self) -> u32 { + self.inner.num_tracked_paulis() + } + + /// Convert to a decomposed (graphlike) DEM string. + /// + /// Mechanisms with <= 2 detectors pass through unchanged. + /// Hyperedges (3+ detectors) cannot be decomposed without Pauli + /// provenance and will raise an error. + /// + /// For proper decomposition, use ``coherent_dem_decomposed()`` + /// or ``noise_characterization()`` which track X/Z components. + fn to_string_decomposed(&self) -> PyResult { + self.inner + .to_string_decomposed() + .map_err(pyo3::exceptions::PyValueError::new_err) } /// Aggregate mechanisms by their effect. @@ -2551,260 +4035,1499 @@ impl PyParsedDem { dict.set_item(key_tuple, prob)?; } - Ok(dict.unbind()) - } + Ok(dict.unbind()) + } + + /// Sample from this DEM. + /// + /// Args: + /// seed: Optional random seed for reproducibility. + /// + /// Returns: + /// Tuple of (`detector_events`, `dem_output_flips`) as boolean lists. + #[pyo3(signature = (seed=None))] + fn sample(&self, seed: Option) -> (Vec, Vec) { + use pecos_random::PecosRng; + use rand::RngExt; + + let mut rng = match seed { + Some(s) => PecosRng::seed_from_u64(s), + None => PecosRng::seed_from_u64(rand::rng().random()), + }; + + self.inner.sample(&mut rng) + } + + /// Sample multiple shots from this DEM. + /// + /// Args: + /// `num_shots`: Number of shots to sample. + /// seed: Optional random seed for reproducibility. + /// + /// Returns: + /// Tuple of (`all_detector_events`, `all_dem_output_flips`). + #[pyo3(signature = (num_shots, seed=None))] + fn sample_batch( + &self, + num_shots: usize, + seed: Option, + ) -> (Vec>, Vec>) { + use pecos_random::PecosRng; + use rand::RngExt; + + let mut rng = match seed { + Some(s) => PecosRng::seed_from_u64(s), + None => PecosRng::seed_from_u64(rand::rng().random()), + }; + + self.inner.sample_batch(num_shots, &mut rng) + } + + /// Convert to an optimized `DemSampler` for fast batch sampling. + /// + /// The `DemSampler` uses geometric skip sampling and parallel chunked + /// processing, which is significantly faster than `sample_batch` for + /// large shot counts and low error rates. + /// + /// Returns: + /// `DemSampler`: Optimized sampler for this DEM. + /// + /// Example: + /// >>> dem = `ParsedDem.from_string("error(0.01)` D0 D1") + /// >>> sampler = `dem.to_dem_sampler()` + /// >>> stats = `sampler.sample_statistics(100000`, seed=42) + fn to_dem_sampler(&self) -> PyDemSampler { + PyDemSampler { + inner: self.inner.to_dem_sampler(), + } + } + + fn __repr__(&self) -> String { + format!( + "ParsedDem(mechanisms={}, detectors={}, dem_outputs={}, observables={}, tracked_paulis={})", + self.inner.mechanisms.len(), + self.inner.num_detectors, + self.inner.num_dem_outputs(), + self.inner.num_observables(), + self.inner.num_tracked_paulis() + ) + } +} + +/// Compare two DEMs for exact mechanism match. +/// +/// This comparison aggregates mechanisms by effect and compares probabilities. +/// Appropriate for non-decomposed DEMs or when exact match is required. +/// +/// Args: +/// dem1: First DEM string or `ParsedDem`. +/// dem2: Second DEM string or `ParsedDem`. +/// `prob_tolerance`: Relative tolerance for probability comparison (default 1e-6). +/// +/// Returns: +/// `EquivalenceResult` with comparison statistics. +/// +/// Example: +/// >>> result = `compare_dems_exact(dem1_str`, `dem2_str`, `prob_tolerance=0.001`) +/// >>> if result.equivalent: +/// ... print("DEMs are equivalent") +#[pyfunction] +#[pyo3(signature = (dem1, dem2, prob_tolerance=1e-6))] +fn compare_dems_exact( + dem1: &str, + dem2: &str, + prob_tolerance: f64, +) -> PyResult { + let parsed1 = dem1 + .parse::() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM1 parse error: {e}")))?; + let parsed2 = dem2 + .parse::() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM2 parse error: {e}")))?; + + let inner = rust_compare_dems_exact(&parsed1, &parsed2, prob_tolerance); + Ok(PyEquivalenceResult { inner }) +} + +/// Compare two DEMs statistically by sampling. +/// +/// This is the most robust comparison method as it accounts for all +/// decomposition strategies and probability combinations. It compares +/// the joint distribution of syndrome patterns, not just marginal rates. +/// +/// Args: +/// dem1: First DEM string or `ParsedDem`. +/// dem2: Second DEM string or `ParsedDem`. +/// `num_shots`: Number of shots for sampling (default 100,000). +/// seed: Random seed (default 42). +/// tolerance: Maximum relative difference to consider equivalent (default 0.05). +/// +/// Returns: +/// `EquivalenceResult` with comparison statistics. +/// +/// Example: +/// >>> result = `compare_dems_statistical(dem1_str`, `dem2_str`, `num_shots=50000`) +/// >>> print(f"Correlation: {result.correlation}") +#[pyfunction] +#[pyo3(signature = (dem1, dem2, num_shots=100_000, seed=42, tolerance=0.05))] +fn compare_dems_statistical( + dem1: &str, + dem2: &str, + num_shots: usize, + seed: u64, + tolerance: f64, +) -> PyResult { + let parsed1 = dem1 + .parse::() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM1 parse error: {e}")))?; + let parsed2 = dem2 + .parse::() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM2 parse error: {e}")))?; + + let inner = rust_compare_dems_statistical(&parsed1, &parsed2, num_shots, seed, tolerance); + Ok(PyEquivalenceResult { inner }) +} + +/// Convenience function to verify DEM equivalence. +/// +/// Args: +/// dem1: First DEM string. +/// dem2: Second DEM string. +/// method: Comparison method - "exact" or "statistical" (default "exact"). +/// `prob_tolerance`: For exact: probability tolerance (default 1e-6). +/// `num_shots`: For statistical: number of shots (default 100,000). +/// tolerance: For statistical: rate tolerance (default 0.05). +/// seed: For statistical: random seed (default 42). +/// +/// Returns: +/// True if DEMs are equivalent within tolerance. +/// +/// Example: +/// >>> if `verify_dem_equivalence(dem1`, dem2, method="exact"): +/// ... print("DEMs match exactly") +#[pyfunction] +#[pyo3(signature = (dem1, dem2, method="exact", prob_tolerance=1e-6, num_shots=100_000, tolerance=0.05, seed=42))] +fn verify_dem_equivalence( + dem1: &str, + dem2: &str, + method: &str, + prob_tolerance: f64, + num_shots: usize, + tolerance: f64, + seed: u64, +) -> PyResult { + let comparison_method = match method { + "exact" => RustComparisonMethod::Exact { prob_tolerance }, + "statistical" => RustComparisonMethod::Statistical { + num_shots, + seed, + tolerance, + }, + _ => { + return Err(pyo3::exceptions::PyValueError::new_err( + "method must be 'exact' or 'statistical'", + )); + } + }; + + rust_verify_dem_equivalence(dem1, dem2, &comparison_method) + .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) +} + +/// Assert that two DEMs are equivalent, raising an error if not. +/// +/// This is a convenience function for testing that raises `AssertionError` +/// if the DEMs are not equivalent. +/// +/// Args: +/// dem1: First DEM string. +/// dem2: Second DEM string. +/// method: Comparison method - "exact" or "statistical" (default "exact"). +/// `prob_tolerance`: For exact: probability tolerance (default 1e-6). +/// `num_shots`: For statistical: number of shots (default 100,000). +/// tolerance: For statistical: rate tolerance (default 0.05). +/// seed: For statistical: random seed (default 42). +/// +/// Raises: +/// `AssertionError`: If DEMs are not equivalent. +/// +/// Example: +/// >>> `assert_dems_equivalent(dem1`, dem2, method="exact") # Raises if not equivalent +#[pyfunction] +#[pyo3(signature = (dem1, dem2, method="exact", prob_tolerance=1e-6, num_shots=100_000, tolerance=0.05, seed=42))] +fn assert_dems_equivalent( + dem1: &str, + dem2: &str, + method: &str, + prob_tolerance: f64, + num_shots: usize, + tolerance: f64, + seed: u64, +) -> PyResult<()> { + let parsed1 = dem1 + .parse::() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM1 parse error: {e}")))?; + let parsed2 = dem2 + .parse::() + .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM2 parse error: {e}")))?; + + let result = match method { + "exact" => rust_compare_dems_exact(&parsed1, &parsed2, prob_tolerance), + "statistical" => { + rust_compare_dems_statistical(&parsed1, &parsed2, num_shots, seed, tolerance) + } + _ => { + return Err(pyo3::exceptions::PyValueError::new_err( + "method must be 'exact' or 'statistical'", + )); + } + }; + + if result.equivalent { + Ok(()) + } else { + let msg = format!( + "DEMs are not equivalent: max_rate_diff={:.6}, only_in_dem1={:?}, only_in_dem2={:?}", + result.max_rate_difference, result.details.only_in_dem1, result.details.only_in_dem2 + ); + Err(pyo3::exceptions::PyAssertionError::new_err(msg)) + } +} + +// ============================================================================= +// CSS UF Decoder (UIUF) +// ============================================================================= + +/// CSS-aware Union-Find decoder using the UIUF algorithm. +/// +/// Takes separate X and Z DEM strings and decodes them jointly, exploiting +/// Y-error identification through cluster intersection. +/// +/// `Example::` +/// +/// decoder = CssUfDecoder(x_dem_str, z_dem_str) +/// x_obs, z_obs = decoder.decode_css(x_syndrome, z_syndrome) +/// +#[pyclass(name = "CssUfDecoder", module = "pecos_rslib.qec")] +pub struct PyCssUfDecoder { + inner: pecos_decoders::CssUfDecoder, +} + +#[pymethods] +impl PyCssUfDecoder { + /// Create a CSS UF decoder from X and Z DEM strings. + /// + /// The qubit-edge mapping is auto-detected from detector coordinates. + /// If coordinates are missing, falls back to independent X/Z decoding. + #[new] + fn new(x_dem: &str, z_dem: &str) -> PyResult { + let inner = pecos_decoders::CssUfDecoder::from_dems( + x_dem, + z_dem, + pecos_decoders::UfDecoderConfig::accurate(), + ) + .map_err(|e| PyErr::new::(e.to_string()))?; + Ok(Self { inner }) + } + + /// Decode X and Z syndromes jointly using UIUF. + /// + /// Args: + /// `x_syndrome`: X-basis detection events (bytes). + /// `z_syndrome`: Z-basis detection events (bytes). + /// + /// Returns: + /// Tuple of (`x_observable_mask`, `z_observable_mask`). + fn decode_css(&mut self, x_syndrome: &[u8], z_syndrome: &[u8]) -> PyResult<(u64, u64)> { + self.inner + .decode_css(x_syndrome, z_syndrome) + .map_err(|e| PyErr::new::(e.to_string())) + } + + /// Number of matched qubit pairs between X and Z graphs. + /// 0 means no mapping was found (falls back to independent decode). + #[getter] + fn num_qubit_pairs(&self) -> usize { + self.inner.num_qubit_pairs() + } + + /// Count erasures the intersection would produce for given syndromes. + fn count_erasures(&mut self, x_syndrome: &[u8], z_syndrome: &[u8]) -> usize { + self.inner + .count_intersection_erasures(x_syndrome, z_syndrome) + } + + /// Decode a batch of syndromes and return the error count. + /// + /// Each shot has concatenated `[x_syndrome | z_syndrome]`. + /// The `x_syndrome` length is specified by `x_num_detectors`. + /// + /// Args: + /// syndromes: List of concatenated syndrome byte arrays. + /// `true_obs_masks`: True observable masks for each shot. + /// `x_num_detectors`: Length of the X syndrome prefix. + /// + /// Returns: + /// Number of logical errors. + fn decode_count_batch( + &mut self, + syndromes: Vec>, + true_obs_masks: Vec, + x_num_detectors: usize, + ) -> PyResult { + let mut errors = 0; + for (syn, &true_obs) in syndromes.iter().zip(true_obs_masks.iter()) { + let x_syn = &syn[..x_num_detectors.min(syn.len())]; + let z_syn = &syn[x_num_detectors.min(syn.len())..]; + let (x_obs, z_obs) = self + .inner + .decode_css(x_syn, z_syn) + .map_err(|e| PyErr::new::(e.to_string()))?; + let predicted = x_obs ^ z_obs; + if predicted != true_obs { + errors += 1; + } + } + Ok(errors) + } +} + +// ============================================================================= +// Observable Subgraph Decoder (Python class) +// ============================================================================= + +/// Per-observable subgraph decoder for transversal gates. +/// +/// Partitions a DEM into per-observable graphlike subgraphs using +/// stabilizer coordinate information, then decodes each independently. +/// +/// Args: +/// 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`"). +/// +/// Example: +/// >>> decoder = `ObservableSubgraphDecoder`( +/// ... `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, +} + +#[pymethods] +impl PyObservableSubgraphDecoder { + #[new] + #[pyo3(signature = (dem, stab_coords, inner_decoder="pecos_uf:fast", max_time_radius=None))] + fn new( + dem: &str, + stab_coords: Vec>, + inner_decoder: &str, + max_time_radius: Option, + ) -> PyResult { + use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + + // Parse stab_coords from Python dicts + let mut rust_stab_coords = Vec::with_capacity(stab_coords.len()); + for dict in &stab_coords { + let x_list: Vec<(f64, f64)> = dict + .get_item("X")? + .ok_or_else(|| PyErr::new::("Missing 'X' key"))? + .extract()?; + let z_list: Vec<(f64, f64)> = dict + .get_item("Z")? + .ok_or_else(|| PyErr::new::("Missing 'Z' key"))? + .extract()?; + rust_stab_coords.push(QubitStabCoords { + x_positions: x_list, + z_positions: z_list, + }); + } + + let inner = ObservableSubgraphDecoder::from_dem_windowed( + dem, + &rust_stab_coords, + max_time_radius, + |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; + self.inner + .decode_to_observables(&syndrome) + .map_err(|e| PyErr::new::(e.to_string())) + } + + /// Number of observables this decoder handles. + fn num_observables(&self) -> usize { + self.inner.num_observables() + } + + /// Decode a batch of syndromes and return observable predictions. + /// + /// Args: + /// 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> { + use pecos_decoder_core::ObservableDecoder; + let mut results = Vec::with_capacity(syndromes.len()); + for syn in &syndromes { + let obs = self + .inner + .decode_to_observables(syn) + .map_err(|e| PyErr::new::(e.to_string()))?; + results.push(obs); + } + Ok(results) + } + + /// Decode a `SampleBatch` and return the number of logical errors. + /// + /// This runs entirely in Rust — no Python per-shot overhead. + /// + /// Args: + /// batch: A `SampleBatch` from `DemSampler.generate_samples()`. + /// + /// Returns: + /// Number of logical errors. + 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_batched(&detection_events, &observable_masks) + .map_err(|e| PyErr::new::(e.to_string())) + } + + /// 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))] + fn decode_count_parallel( + &self, + batch: &PySampleBatch, + dem: &str, + stab_coords: Vec>, + inner_decoder: &str, + num_workers: Option, + max_time_radius: Option, + ) -> PyResult { + use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + use rayon::prelude::*; + + // Parse stab_coords + let mut sc = Vec::with_capacity(stab_coords.len()); + for dict in &stab_coords { + let x: Vec<(f64, f64)> = dict + .get_item("X")? + .ok_or_else(|| PyErr::new::("X"))? + .extract()?; + let z: Vec<(f64, f64)> = dict + .get_item("Z")? + .ok_or_else(|| PyErr::new::("Z"))? + .extract()?; + sc.push(QubitStabCoords { + x_positions: x, + z_positions: z, + }); + } + + let dem_str = dem.to_string(); + let inner_str = inner_decoder.to_string(); + let n = batch.num_shots; + + // Materialize row-major data for parallel decode. + let events: Vec> = (0..n) + .map(|i| { + let mut s = vec![0u8; batch.num_detectors]; + batch.extract_syndrome(i, &mut s); + s + }) + .collect(); + let masks: Vec = (0..n).map(|i| batch.extract_obs_mask(i)).collect(); + + let pool = rayon::ThreadPoolBuilder::new() + .num_threads(num_workers.unwrap_or(0)) + .build() + .map_err(|e| PyErr::new::(e.to_string()))?; + + let errors: usize = 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) + .collect::>() + .par_chunks(chunk_size.max(1)) + .map(|chunk| { + // Build a fresh decoder for this worker + let mut dec = ObservableSubgraphDecoder::from_dem_windowed( + &dem_str, + &sc, + max_time_radius, + |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()) + })?; + 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() + }); + + Ok(errors) + } + + /// Number of detectors in each subgraph. + fn subgraph_sizes(&self) -> Vec { + (0..self.inner.num_observables()) + .map(|i| self.inner.subgraph(i).map_or(0, |sg| sg.detector_map.len())) + .collect() + } + + /// Diagnostics: (`num_edges`, `skipped_hyperedges`) for each subgraph. + fn subgraph_diagnostics(&self) -> Vec<(usize, usize)> { + (0..self.inner.num_observables()) + .map(|i| { + self.inner.subgraph(i).map_or((0, 0), |sg| { + (sg.graph.edges.len(), sg.graph.skipped_hyperedges) + }) + }) + .collect() + } + + /// Count ghost edges (3-detector cross-qubit hyperedges) in the DEM. + /// + /// These are the hyperedges that the ghost protocol decomposes for + /// modular per-qubit decoding. Returns (`total_ghost_edges`, `num_qubits`). + #[staticmethod] + fn count_ghost_edges( + dem: &str, + stab_coords: Vec>, + ) -> PyResult<(usize, usize)> { + use pecos_decoder_core::ghost_protocol::extract_ghost_edges_from_dem; + use pecos_decoder_core::observable_subgraph::QubitStabCoords; + + let mut sc = Vec::with_capacity(stab_coords.len()); + for dict in &stab_coords { + let x: Vec<(f64, f64)> = dict + .get_item("X")? + .ok_or_else(|| PyErr::new::("X"))? + .extract()?; + let z: Vec<(f64, f64)> = dict + .get_item("Z")? + .ok_or_else(|| PyErr::new::("Z"))? + .extract()?; + sc.push(QubitStabCoords { + x_positions: x, + z_positions: z, + }); + } + + let edges = extract_ghost_edges_from_dem(dem, &sc); + let num_qubits = sc.len(); + 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. + fn subgraph_dems(&self) -> Vec { + (0..self.inner.num_observables()) + .map(|i| { + self.inner + .subgraph(i) + .map_or(String::new(), |sg| subgraph_to_dem_string(&sg.graph)) + }) + .collect() + } + + /// Get the detector map for each subgraph (local → global index mapping). + fn subgraph_detector_maps(&self) -> Vec> { + (0..self.inner.num_observables()) + .map(|i| { + self.inner + .subgraph(i) + .map_or(Vec::new(), |sg| sg.detector_map.clone()) + }) + .collect() + } +} + +// ============================================================================= +// Windowed OSD Decoder (Python class) +// ============================================================================= + +/// Windowed observable subgraph decoder for deep circuits. +/// +/// Splits the DEM into time windows, runs OSD within each window. +/// Prevents the observing region from spanning the full circuit. +/// +/// 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). +#[pyclass(name = "WindowedOsdDecoder", module = "pecos_rslib.qec")] +pub struct PyWindowedOsdDecoder { + inner: pecos_decoder_core::windowed_osd::WindowedOsdDecoder, +} + +#[pymethods] +impl PyWindowedOsdDecoder { + #[new] + #[pyo3(signature = (dem, stab_coords, inner_decoder="pymatching", step=8, buffer=4))] + fn new( + dem: &str, + stab_coords: Vec>, + inner_decoder: &str, + step: usize, + buffer: usize, + ) -> PyResult { + use pecos_decoder_core::observable_subgraph::QubitStabCoords; + use pecos_decoder_core::windowed_osd::{WindowedOsdConfig, WindowedOsdDecoder}; + + let mut sc = Vec::with_capacity(stab_coords.len()); + for dict in &stab_coords { + let x: Vec<(f64, f64)> = dict + .get_item("X")? + .ok_or_else(|| PyErr::new::("X"))? + .extract()?; + let z: Vec<(f64, f64)> = dict + .get_item("Z")? + .ok_or_else(|| PyErr::new::("Z"))? + .extract()?; + sc.push(QubitStabCoords { + x_positions: x, + z_positions: z, + }); + } + + let config = WindowedOsdConfig { step, buffer }; + + let inner = WindowedOsdDecoder::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()))?; + + Ok(Self { inner }) + } + + fn decode(&mut self, syndrome: Vec) -> PyResult { + use pecos_decoder_core::ObservableDecoder; + self.inner + .decode_to_observables(&syndrome) + .map_err(|e| PyErr::new::(e.to_string())) + } + + fn decode_count(&mut self, batch: &PySampleBatch) -> PyResult { + 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_to_observables(&syndrome) + .map_err(|e| PyErr::new::(e.to_string()))?; + if predicted != batch.extract_obs_mask(i) { + errors += 1; + } + } + Ok(errors) + } + + fn num_windows(&self) -> usize { + self.inner.windows.len() + } +} + +// ============================================================================= +// Logical Algorithm Decoder (Python class) +// ============================================================================= + +/// Decoder for logical quantum algorithms with per-segment OSD and +/// Pauli frame propagation at transversal gate boundaries. +/// +/// Built from a descriptor dict produced by +/// ``LogicalCircuitBuilder.build_algorithm_descriptor()``. +/// +/// Supports both batch mode (``decode``, ``decode_count``) and +/// streaming mode (``feed_sparse``, ``flush``, ``reset``). +#[pyclass(name = "LogicalAlgorithmDecoder", module = "pecos_rslib.qec")] +pub struct PyLogicalAlgorithmDecoder { + inner: pecos_decoder_core::logical_algorithm::StreamingLogicalDecoder, +} + +#[pymethods] +impl PyLogicalAlgorithmDecoder { + /// Build from a descriptor dict and inner decoder type. + /// + /// Args: + /// descriptor: Dict from ``LogicalCircuitBuilder.build_algorithm_descriptor()``. + /// `inner_decoder`: Decoder type string for each segment's OSD inner decoder. + #[new] + #[pyo3(signature = (descriptor, inner_decoder="pymatching"))] + fn new( + descriptor: &pyo3::Bound<'_, pyo3::types::PyDict>, + inner_decoder: &str, + ) -> PyResult { + use pecos_decoder_core::logical_algorithm::{ + AlgorithmDescriptor, BoundaryGate, LogicalAlgorithmDecoder, SegmentDescriptor, + }; + use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + + // Parse full DEM and stab_coords for full-circuit OSD + let full_dem: String = descriptor + .get_item("full_dem")? + .ok_or_else(|| PyErr::new::("full_dem"))? + .extract()?; + + // Use first segment's stab_coords as the base (they have the + // original X/Z assignment; the full-circuit DEM uses original coords). + let seg_list: Vec> = descriptor + .get_item("segments")? + .ok_or_else(|| PyErr::new::("segments"))? + .extract()?; + + let num_obs: usize = descriptor + .get_item("num_observables")? + .ok_or_else(|| PyErr::new::("num_observables"))? + .extract()?; + + // Parse stab_coords from the first segment (original orientation) + let first_seg = &seg_list[0]; + let sc_list: Vec> = first_seg + .get_item("stab_coords")? + .ok_or_else(|| PyErr::new::("stab_coords"))? + .extract()?; + let mut rust_sc = Vec::with_capacity(sc_list.len()); + for sc_dict in &sc_list { + let x: Vec<(f64, f64)> = sc_dict + .get_item("X")? + .ok_or_else(|| PyErr::new::("X"))? + .extract()?; + let z: Vec<(f64, f64)> = sc_dict + .get_item("Z")? + .ok_or_else(|| PyErr::new::("Z"))? + .extract()?; + rust_sc.push(QubitStabCoords { + x_positions: x, + z_positions: z, + }); + } + + 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| { + 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()))?; + Ok(Box::new(SendWrapper(d)) + as Box) + }) + .map_err(|e| PyErr::new::(e.to_string()))?; + + // Parse segment descriptors (for metadata) + let mut seg_descs = Vec::with_capacity(seg_list.len()); + for seg_dict in &seg_list { + let n_det: usize = seg_dict + .get_item("num_detectors")? + .ok_or_else(|| PyErr::new::("num_detectors"))? + .extract()?; + seg_descs.push(SegmentDescriptor { + num_detectors: n_det, + num_observables: num_obs, + }); + } + + // Parse boundary gates + let bg_list: Vec>> = descriptor + .get_item("boundary_gates")? + .ok_or_else(|| PyErr::new::("boundary_gates"))? + .extract()?; + + let mut boundary_gates = Vec::with_capacity(bg_list.len()); + for gates in &bg_list { + let mut bg_vec = Vec::new(); + for gate_dict in gates { + let gate_type: String = gate_dict + .get_item("type")? + .ok_or_else(|| PyErr::new::("type"))? + .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, + }); + } + "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()?, + }); + } + "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, + }); + } + "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()?; + bg_vec.push(BoundaryGate::TGateInjection { + z_obs_bit: z, + ancilla_z_bit: a, + }); + } + _ => { + return Err(PyErr::new::(format!( + "Unknown gate type: {gate_type}" + ))); + } + } + } + boundary_gates.push(bg_vec); + } + + let algo_desc = AlgorithmDescriptor { + segments: seg_descs, + boundary_gates, + num_observables: num_obs, + }; + + let algo_dec = LogicalAlgorithmDecoder::new(Box::new(full_osd), algo_desc); + let inner = pecos_decoder_core::logical_algorithm::StreamingLogicalDecoder::new(algo_dec); + Ok(Self { inner }) + } + + // -- Batch mode -- + + /// Decode a single syndrome and return observable flip mask. + fn decode(&mut self, syndrome: Vec) -> PyResult { + self.inner.reset(); + self.inner + .decode_shot(&syndrome) + .map_err(|e| PyErr::new::(e.to_string())) + } + + /// Decode a batch of samples and count logical errors. + 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())) + } + + // -- Streaming mode -- + + /// Feed sparse detection events: list of (`detector_index`, value) pairs. + fn feed_sparse(&mut self, detectors: Vec<(u32, u8)>) { + self.inner.feed_sparse(&detectors); + } + + /// Feed a dense syndrome (all detectors in order). + fn feed_dense(&mut self, syndrome: Vec) { + self.inner.feed_dense(&syndrome); + } + + /// Decode the accumulated syndrome. Call at segment boundaries or end. + fn flush(&mut self) -> PyResult { + self.inner + .flush() + .map_err(|e| PyErr::new::(e.to_string())) + } + + /// Reset syndrome buffer for the next shot. + fn reset(&mut self) { + self.inner.reset(); + } + + /// Current accumulated observable correction. + fn accumulated_obs(&self) -> u64 { + self.inner.accumulated_obs() + } + + // -- Metadata -- + + /// Number of segments. + fn num_segments(&self) -> usize { + self.inner.num_segments() + } + + /// Rounds fed so far. + fn rounds_fed(&self) -> usize { + self.inner.rounds_fed() + } +} + +// ============================================================================= +// Logical Circuit Decoder with Budget (Python class) +// ============================================================================= + +/// 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) +/// - ``"10ms"``, ``"1000us"``, etc.: explicit reaction time budget +/// +/// The reaction time is the time available at feed-forward decision +/// points (T gates, magic state injection). For Clifford-only circuits, +/// use ``"unlimited"`` since there are no mid-circuit decisions. +/// +/// `Example::` +/// +/// desc = builder.build_algorithm_descriptor(p1=0.001, p2=0.001) +/// decoder = LogicalCircuitDecoder(desc, budget="unlimited") +/// errors = decoder.decode_count(batch) +#[pyclass(name = "LogicalCircuitDecoder", module = "pecos_rslib.qec")] +pub struct PyLogicalCircuitDecoder { + inner: pecos_decoder_core::logical_algorithm::LogicalCircuitDecoder, +} + +#[pymethods] +impl PyLogicalCircuitDecoder { + #[new] + #[pyo3(signature = (descriptor, budget="unlimited", inner_decoder="pymatching"))] + fn new( + descriptor: &pyo3::Bound<'_, pyo3::types::PyDict>, + budget: &str, + inner_decoder: &str, + ) -> PyResult { + use pecos_decoder_core::decode_budget::DecodeBudget; + use pecos_decoder_core::logical_algorithm::{ + AlgorithmDescriptor, BoundaryGate, FullCircuitStrategy, LogicalCircuitDecoder, + SegmentDescriptor, + }; + use pecos_decoder_core::observable_subgraph::{ObservableSubgraphDecoder, QubitStabCoords}; + + // Parse full DEM + let full_dem: String = descriptor + .get_item("full_dem")? + .ok_or_else(|| PyErr::new::("full_dem"))? + .extract()?; + + let seg_list: Vec> = descriptor + .get_item("segments")? + .ok_or_else(|| PyErr::new::("segments"))? + .extract()?; + + let num_obs: usize = descriptor + .get_item("num_observables")? + .ok_or_else(|| PyErr::new::("num_observables"))? + .extract()?; + + // Parse stab_coords from first segment + let first_seg = &seg_list[0]; + let sc_list: Vec> = first_seg + .get_item("stab_coords")? + .ok_or_else(|| PyErr::new::("stab_coords"))? + .extract()?; + let mut rust_sc = Vec::with_capacity(sc_list.len()); + for sc_dict in &sc_list { + let x: Vec<(f64, f64)> = sc_dict + .get_item("X")? + .ok_or_else(|| PyErr::new::("X"))? + .extract()?; + let z: Vec<(f64, f64)> = sc_dict + .get_item("Z")? + .ok_or_else(|| PyErr::new::("Z"))? + .extract()?; + rust_sc.push(QubitStabCoords { + x_positions: x, + z_positions: z, + }); + } + 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 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()))?; + Ok(Box::new(SendWrapper(d)) + as Box) + }) + .map_err(|e| PyErr::new::(e.to_string()))?; + + // Parse segments + let mut seg_descs = Vec::with_capacity(seg_list.len()); + for seg_dict in &seg_list { + let n_det: usize = seg_dict + .get_item("num_detectors")? + .ok_or_else(|| PyErr::new::("num_detectors"))? + .extract()?; + seg_descs.push(SegmentDescriptor { + num_detectors: n_det, + num_observables: num_obs, + }); + } + + // Parse boundary gates + let bg_list: Vec>> = descriptor + .get_item("boundary_gates")? + .ok_or_else(|| PyErr::new::("boundary_gates"))? + .extract()?; + + let mut boundary_gates = Vec::with_capacity(bg_list.len()); + for gates in &bg_list { + let mut bg_vec = Vec::new(); + for gate_dict in gates { + let gate_type: String = gate_dict + .get_item("type")? + .ok_or_else(|| PyErr::new::("type"))? + .extract()?; + 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()?, + }); + } + "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()?, + }); + } + "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()?, + }); + } + "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()?; + bg_vec.push(BoundaryGate::TGateInjection { + z_obs_bit: z, + ancilla_z_bit: a, + }); + } + _ => { + return Err(PyErr::new::(format!( + "Unknown gate type: {gate_type}" + ))); + } + } + } + boundary_gates.push(bg_vec); + } + + let algo_desc = AlgorithmDescriptor { + segments: seg_descs, + boundary_gates, + num_observables: num_obs, + }; + + // 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; + } + let decode_budget = match budget { + "unlimited" | "offline" => DecodeBudget::unlimited(), + "windowed" => { + DecodeBudget::from_reaction_time(std::time::Duration::from_millis(1), distance) + } + s if s.ends_with("us") => { + let us: u64 = s[..s.len() - 2].parse().map_err(|_| { + PyErr::new::(format!( + "Invalid cycle time: {s}" + )) + })?; + DecodeBudget::from_reaction_time(std::time::Duration::from_micros(us), distance) + } + s if s.ends_with("ms") => { + let ms: u64 = s[..s.len() - 2].parse().map_err(|_| { + PyErr::new::(format!( + "Invalid cycle time: {s}" + )) + })?; + DecodeBudget::from_reaction_time(std::time::Duration::from_millis(ms), distance) + } + _ => { + return Err(PyErr::new::(format!( + "Unknown budget: {budget}. Use: unlimited, windowed, or a cycle time like 1000us, 10ms" + ))); + } + }; + + // Select strategy based on budget. + let strategy: Box = + if decode_budget.is_unlimited() { + // Unlimited: full-circuit OSD (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; + + 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()); + } + } - /// Sample from this DEM. - /// - /// Args: - /// seed: Optional random seed for reproducibility. - /// - /// Returns: - /// Tuple of (`detector_events`, `observable_flips`) as boolean lists. - #[pyo3(signature = (seed=None))] - fn sample(&self, seed: Option) -> (Vec, Vec) { - use pecos_random::PecosRng; - use rand::RngExt; + 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 wosd = WindowedOsdStrategy::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)) + as Box) + }) + .map_err(|e| PyErr::new::(e.to_string()))?; - let mut rng = match seed { - Some(s) => PecosRng::seed_from_u64(s), - None => PecosRng::seed_from_u64(rand::rng().random()), - }; + Box::new(wosd) + }; - self.inner.sample(&mut rng) + let inner = LogicalCircuitDecoder::new(algo_desc, strategy, decode_budget, num_qubits); + Ok(Self { inner }) } - /// Sample multiple shots from this DEM. - /// - /// Args: - /// `num_shots`: Number of shots to sample. - /// seed: Optional random seed for reproducibility. - /// - /// Returns: - /// Tuple of (`all_detector_events`, `all_observable_flips`). - #[pyo3(signature = (num_shots, seed=None))] - fn sample_batch( - &self, - num_shots: usize, - seed: Option, - ) -> (Vec>, Vec>) { - use pecos_random::PecosRng; - use rand::RngExt; + /// Decode a single syndrome. + fn decode(&mut self, syndrome: Vec) -> PyResult { + use pecos_decoder_core::ObservableDecoder; + self.inner + .decode_to_observables(&syndrome) + .map_err(|e| PyErr::new::(e.to_string())) + } - let mut rng = match seed { - Some(s) => PecosRng::seed_from_u64(s), - None => PecosRng::seed_from_u64(rand::rng().random()), - }; + /// Decode a batch and count errors. + 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())) + } - self.inner.sample_batch(num_shots, &mut rng) + /// Number of segments. + fn num_segments(&self) -> usize { + self.inner.num_segments() } - /// Convert to an optimized `DemSampler` for fast batch sampling. - /// - /// The `DemSampler` uses geometric skip sampling and parallel chunked - /// processing, which is significantly faster than `sample_batch` for - /// large shot counts and low error rates. - /// - /// Returns: - /// `DemSampler`: Optimized sampler for this DEM. - /// - /// Example: - /// >>> dem = `ParsedDem.from_string("error(0.01)` D0 D1") - /// >>> sampler = `dem.to_dem_sampler()` - /// >>> stats = `sampler.sample_statistics(100000`, seed=42) - fn to_dem_sampler(&self) -> PyDemSampler { - PyDemSampler { - inner: self.inner.to_dem_sampler(), - } + /// Total detectors. + fn total_detectors(&self) -> usize { + self.inner.total_detectors() } - fn __repr__(&self) -> String { - format!( - "ParsedDem(mechanisms={}, detectors={}, observables={})", - self.inner.mechanisms.len(), - self.inner.num_detectors, - self.inner.num_observables - ) + /// Whether the circuit has feed-forward decision points (T gates). + /// If False, the reaction time budget doesn't matter — Clifford only. + fn has_decision_points(&self) -> bool { + self.inner.has_decision_points() + } + + /// Number of decision points. + fn num_decision_points(&self) -> usize { + self.inner.num_decision_points() + } + + /// Reset for next shot. + fn reset(&mut self) { + self.inner.reset(); } } -/// Compare two DEMs for exact mechanism match. -/// -/// This comparison aggregates mechanisms by effect and compares probabilities. -/// Appropriate for non-decomposed DEMs or when exact match is required. +// ============================================================================= +// Correlation Analysis Functions +// ============================================================================= + +/// Compute a detector flip frequency matrix from fired-detector lists. /// /// Args: -/// dem1: First DEM string or `ParsedDem`. -/// dem2: Second DEM string or `ParsedDem`. -/// `prob_tolerance`: Relative tolerance for probability comparison (default 1e-6). +/// fired_per_shot: List of lists, each inner list contains the detector +/// indices that fired in that shot (sorted ascending). +/// num_detectors: Total number of detectors. /// /// Returns: -/// `EquivalenceResult` with comparison statistics. -/// -/// Example: -/// >>> result = `compare_dems_exact(dem1_str`, `dem2_str`, `prob_tolerance=0.001`) -/// >>> if result.equivalent: -/// ... print("DEMs are equivalent") +/// Flat list of length ``num_detectors^2`` (row-major). Diagonal entries +/// are marginal rates; off-diagonal ``M[i*n+j]`` = 0.5 * P(i AND j fire). #[pyfunction] -#[pyo3(signature = (dem1, dem2, prob_tolerance=1e-6))] -fn compare_dems_exact( - dem1: &str, - dem2: &str, - prob_tolerance: f64, -) -> PyResult { - let parsed1 = dem1 - .parse::() - .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM1 parse error: {e}")))?; - let parsed2 = dem2 - .parse::() - .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM2 parse error: {e}")))?; - - let inner = rust_compare_dems_exact(&parsed1, &parsed2, prob_tolerance); - Ok(PyEquivalenceResult { inner }) +#[pyo3(signature = (fired_per_shot, num_detectors))] +fn detector_flip_matrix(fired_per_shot: Vec>, num_detectors: usize) -> Vec { + pecos_qec::fault_tolerance::correlation::flip_matrix_from_fired(&fired_per_shot, num_detectors) } -/// Compare two DEMs statistically by sampling. +/// Compute per-round detector flip frequency matrices. /// -/// This is the most robust comparison method as it accounts for all -/// decomposition strategies and probability combinations. It compares -/// the joint distribution of syndrome patterns, not just marginal rates. -/// -/// Args: -/// dem1: First DEM string or `ParsedDem`. -/// dem2: Second DEM string or `ParsedDem`. -/// `num_shots`: Number of shots for sampling (default 100,000). -/// seed: Random seed (default 42). -/// tolerance: Maximum relative difference to consider equivalent (default 0.05). +/// Returns a list of flat matrices, one per round. +#[pyfunction] +#[pyo3(signature = (fired_per_shot, num_detectors, dets_per_round))] +fn detector_flip_matrices_by_round( + fired_per_shot: Vec>, + num_detectors: usize, + dets_per_round: usize, +) -> Vec> { + pecos_qec::fault_tolerance::correlation::flip_matrices_by_round( + &fired_per_shot, + num_detectors, + dets_per_round, + ) +} + +/// Compute k-body detector firing rates up to a given order. /// -/// Returns: -/// `EquivalenceResult` with comparison statistics. +/// Returns a list of ``(detector_indices, rate)`` pairs where +/// ``detector_indices`` is a tuple of sorted detector indices. +#[pyfunction] +#[pyo3(signature = (fired_per_shot, num_detectors, max_order=3))] +fn detector_k_body_rates( + fired_per_shot: Vec>, + num_detectors: usize, + max_order: usize, +) -> Vec<(Vec, f64)> { + pecos_qec::fault_tolerance::correlation::k_body_rates(&fired_per_shot, num_detectors, max_order) + .into_iter() + .collect() +} + +/// Compute per-round k-body detector firing rates. /// -/// Example: -/// >>> result = `compare_dems_statistical(dem1_str`, `dem2_str`, `num_shots=50000`) -/// >>> print(f"Correlation: {result.correlation}") +/// Returns a list (one per round) of lists of ``(local_indices, rate)`` pairs. #[pyfunction] -#[pyo3(signature = (dem1, dem2, num_shots=100_000, seed=42, tolerance=0.05))] -fn compare_dems_statistical( - dem1: &str, - dem2: &str, - num_shots: usize, - seed: u64, - tolerance: f64, -) -> PyResult { - let parsed1 = dem1 - .parse::() - .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM1 parse error: {e}")))?; - let parsed2 = dem2 - .parse::() - .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM2 parse error: {e}")))?; +#[pyo3(signature = (fired_per_shot, num_detectors, dets_per_round, max_order=3))] +fn detector_k_body_rates_by_round( + fired_per_shot: Vec>, + num_detectors: usize, + dets_per_round: usize, + max_order: usize, +) -> Vec, f64)>> { + pecos_qec::fault_tolerance::correlation::k_body_rates_by_round( + &fired_per_shot, + num_detectors, + dets_per_round, + max_order, + ) + .into_iter() + .map(|m| m.into_iter().collect()) + .collect() +} - let inner = rust_compare_dems_statistical(&parsed1, &parsed2, num_shots, seed, tolerance); - Ok(PyEquivalenceResult { inner }) +/// Compare two flat flip matrices. Returns (max_rel_err, frob_rel_err, worst_i, worst_j). +#[pyfunction] +#[pyo3(signature = (sim, dem, num_detectors, min_rate=0.0005))] +fn compare_flip_matrices_rs( + sim: Vec, + dem: Vec, + num_detectors: usize, + min_rate: f64, +) -> (f64, f64, usize, usize) { + pecos_qec::fault_tolerance::correlation::compare_flip_matrices( + &sim, + &dem, + num_detectors, + min_rate, + ) } -/// Convenience function to verify DEM equivalence. +/// Compare k-body rates grouped by order. /// /// Args: -/// dem1: First DEM string. -/// dem2: Second DEM string. -/// method: Comparison method - "exact" or "statistical" (default "exact"). -/// `prob_tolerance`: For exact: probability tolerance (default 1e-6). -/// `num_shots`: For statistical: number of shots (default 100,000). -/// tolerance: For statistical: rate tolerance (default 0.05). -/// seed: For statistical: random seed (default 42). +/// sim: List of ``(detector_indices, rate)`` from simulation. +/// dem: List of ``(detector_indices, rate)`` from DEM. +/// min_rate: Minimum rate to consider. /// /// Returns: -/// True if DEMs are equivalent within tolerance. -/// -/// Example: -/// >>> if `verify_dem_equivalence(dem1`, dem2, method="exact"): -/// ... print("DEMs match exactly") +/// List of ``(order, max_rel_err, rms_rel_err, worst_event)`` tuples. #[pyfunction] -#[pyo3(signature = (dem1, dem2, method="exact", prob_tolerance=1e-6, num_shots=100_000, tolerance=0.05, seed=42))] -fn verify_dem_equivalence( - dem1: &str, - dem2: &str, - method: &str, - prob_tolerance: f64, - num_shots: usize, - tolerance: f64, - seed: u64, -) -> PyResult { - let comparison_method = match method { - "exact" => RustComparisonMethod::Exact { prob_tolerance }, - "statistical" => RustComparisonMethod::Statistical { - num_shots, - seed, - tolerance, - }, - _ => { - return Err(pyo3::exceptions::PyValueError::new_err( - "method must be 'exact' or 'statistical'", - )); - } - }; - - rust_verify_dem_equivalence(dem1, dem2, &comparison_method) - .map_err(|e| pyo3::exceptions::PyValueError::new_err(e.to_string())) +#[pyo3(signature = (sim, dem, min_rate=0.0005))] +fn compare_k_body_rates_rs( + sim: Vec<(Vec, f64)>, + dem: Vec<(Vec, f64)>, + min_rate: f64, +) -> Vec<(usize, f64, f64, Vec)> { + let sim_map: std::collections::BTreeMap, f64> = sim.into_iter().collect(); + let dem_map: std::collections::BTreeMap, f64> = dem.into_iter().collect(); + pecos_qec::fault_tolerance::correlation::compare_k_body(&sim_map, &dem_map, min_rate) + .into_iter() + .map(|(order, (me, rms, worst))| (order, me, rms, worst)) + .collect() } -/// Assert that two DEMs are equivalent, raising an error if not. +/// Fit DEM mechanism probabilities to match target detector marginals. /// -/// This is a convenience function for testing that raises `AssertionError` -/// if the DEMs are not equivalent. +/// Takes the mechanism structure (from a stochastic DEM) and exact +/// per-detector marginals (from Heisenberg EEG), and adjusts mechanism +/// probabilities so the DEM reproduces those marginals. /// /// Args: -/// dem1: First DEM string. -/// dem2: Second DEM string. -/// method: Comparison method - "exact" or "statistical" (default "exact"). -/// `prob_tolerance`: For exact: probability tolerance (default 1e-6). -/// `num_shots`: For statistical: number of shots (default 100,000). -/// tolerance: For statistical: rate tolerance (default 0.05). -/// seed: For statistical: random seed (default 42). +/// mechanisms: List of ``(probability, detector_indices, observable_indices)`` +/// from the stochastic DEM. +/// target_marginals: Exact per-detector rates from Heisenberg EEG. +/// max_iterations: Maximum fitting iterations (default 200). +/// tolerance: Convergence threshold (default 1e-12). /// -/// Raises: -/// `AssertionError`: If DEMs are not equivalent. -/// -/// Example: -/// >>> `assert_dems_equivalent(dem1`, dem2, method="exact") # Raises if not equivalent +/// Returns: +/// Tuple of ``(fitted_mechanisms, residuals)`` where +/// ``fitted_mechanisms`` has the same format as input but with +/// adjusted probabilities, and ``residuals`` is the per-detector +/// absolute error after fitting. #[pyfunction] -#[pyo3(signature = (dem1, dem2, method="exact", prob_tolerance=1e-6, num_shots=100_000, tolerance=0.05, seed=42))] -fn assert_dems_equivalent( - dem1: &str, - dem2: &str, - method: &str, - prob_tolerance: f64, - num_shots: usize, +#[pyo3(signature = (mechanisms, target_marginals, max_iterations=200, tolerance=1e-12))] +fn fit_dem_to_marginals( + mechanisms: Vec, + target_marginals: Vec, + max_iterations: usize, tolerance: f64, - seed: u64, -) -> PyResult<()> { - let parsed1 = dem1 - .parse::() - .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM1 parse error: {e}")))?; - let parsed2 = dem2 - .parse::() - .map_err(|e| pyo3::exceptions::PyValueError::new_err(format!("DEM2 parse error: {e}")))?; +) -> PyDemFitResult { + use pecos_qec::fault_tolerance::correlation::{ + DemMechanism, fit_dem_to_marginals as fit_inner, + }; - let result = match method { - "exact" => rust_compare_dems_exact(&parsed1, &parsed2, prob_tolerance), - "statistical" => { - rust_compare_dems_statistical(&parsed1, &parsed2, num_shots, seed, tolerance) - } - _ => { - return Err(pyo3::exceptions::PyValueError::new_err( - "method must be 'exact' or 'statistical'", - )); - } + let mechs: Vec = mechanisms + .iter() + .map(|(p, d, o)| DemMechanism { + probability: *p, + detectors: d.clone(), + observables: o.clone(), + }) + .collect(); + + let (fitted, residuals) = fit_inner(&mechs, &target_marginals, max_iterations, tolerance); + + let result: Vec<(f64, Vec, Vec)> = fitted + .iter() + .map(|m| (m.probability, m.detectors.clone(), m.observables.clone())) + .collect(); + + (result, residuals) +} + +/// Format DEM mechanisms as a standard DEM string. +#[pyfunction] +fn mechanisms_to_dem_string(mechanisms: Vec<(f64, Vec, Vec)>) -> String { + use pecos_qec::fault_tolerance::correlation::{ + DemMechanism, mechanisms_to_dem_string as fmt_inner, }; - if result.equivalent { - Ok(()) - } else { - let msg = format!( - "DEMs are not equivalent: max_rate_diff={:.6}, only_in_dem1={:?}, only_in_dem2={:?}", - result.max_rate_difference, result.details.only_in_dem1, result.details.only_in_dem2 - ); - Err(pyo3::exceptions::PyAssertionError::new_err(msg)) + let mechs: Vec = mechanisms + .iter() + .map(|(p, d, o)| DemMechanism { + probability: *p, + detectors: d.clone(), + observables: o.clone(), + }) + .collect(); + + fmt_inner(&mechs) +} + +/// Query whether a decoder type requires decomposed (graphlike) DEMs. +/// +/// Returns ``"graphlike"`` for MWPM decoders that need decomposed DEMs +/// (hyperedges cause errors), ``"any"`` for decoders that handle both +/// raw and decomposed DEMs. +/// +/// Raises ``ValueError`` for unknown decoder types. +#[pyfunction] +fn decoder_dem_requirement(decoder_type: &str) -> PyResult { + let base = decoder_type.split(':').next().unwrap_or(decoder_type); + match base { + "pymatching" + | "pymatching_uncorrelated" + | "fusion_blossom" + | "fusion_blossom_serial" + | "fusion_blossom_parallel" + | "fusion_blossom_correlated" + | "pecos_uf" + | "pecos_uf_correlated" + | "windowed" + | "k_mwpm" + | "perturbed_fb_corr" + | "perturbed_fb" + | "ensemble" => Ok("graphlike".to_string()), + "tesseract" | "astar" | "astar_full" | "bp_osd" | "bp_lsd" | "union_find" + | "min_sum_bp" | "relay_bp" | "mwpf" | "chromobius" => Ok("any".to_string()), + _ => Err(pyo3::exceptions::PyValueError::new_err(format!( + "Unknown decoder type: {decoder_type:?}", + ))), } } @@ -2822,9 +5545,13 @@ 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::()?; + qec.add_class::()?; + qec.add_class::()?; + qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; qec.add_class::()?; @@ -2836,6 +5563,17 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { qec.add_function(wrap_pyfunction!(verify_dem_equivalence, &qec)?)?; qec.add_function(wrap_pyfunction!(assert_dems_equivalent, &qec)?)?; + // Correlation analysis + qec.add_function(wrap_pyfunction!(detector_flip_matrix, &qec)?)?; + qec.add_function(wrap_pyfunction!(detector_flip_matrices_by_round, &qec)?)?; + qec.add_function(wrap_pyfunction!(detector_k_body_rates, &qec)?)?; + qec.add_function(wrap_pyfunction!(detector_k_body_rates_by_round, &qec)?)?; + qec.add_function(wrap_pyfunction!(compare_flip_matrices_rs, &qec)?)?; + qec.add_function(wrap_pyfunction!(compare_k_body_rates_rs, &qec)?)?; + qec.add_function(wrap_pyfunction!(fit_dem_to_marginals, &qec)?)?; + qec.add_function(wrap_pyfunction!(mechanisms_to_dem_string, &qec)?)?; + qec.add_function(wrap_pyfunction!(decoder_dem_requirement, &qec)?)?; + // Add Pauli constants qec.add("PAULI_I", 0u8)?; qec.add("PAULI_X", 1u8)?; @@ -2844,6 +5582,10 @@ pub fn register_qec_module(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_submodule(&qec)?; + // Keep the common DEM sampler import available at the package root for + // scripts that use `from pecos_rslib import DemSampler`. + m.add("DemSampler", qec.getattr("DemSampler")?)?; + // Register in sys.modules so 'from pecos_rslib.qec import ...' works let sys = m.py().import("sys")?; let modules = sys.getattr("modules")?; diff --git a/python/pecos-rslib/src/gate_registry_bindings.rs b/python/pecos-rslib/src/gate_registry_bindings.rs index b0d718111..5bb237c93 100644 --- a/python/pecos-rslib/src/gate_registry_bindings.rs +++ b/python/pecos-rslib/src/gate_registry_bindings.rs @@ -59,6 +59,7 @@ fn parse_gate_type(name: &str) -> PyResult { "QAlloc" => Ok(GateType::QAlloc), "QFree" => Ok(GateType::QFree), "Idle" => Ok(GateType::Idle), + "TrackedPauli" | "TrackedPauliMeta" | "TP" => Ok(GateType::TrackedPauliMeta), _ => Err(pyo3::exceptions::PyValueError::new_err(format!( "Unknown gate type: '{name}'" ))), diff --git a/python/pecos-rslib/src/lib.rs b/python/pecos-rslib/src/lib.rs index d2c529a08..f22d4f96d 100644 --- a/python/pecos-rslib/src/lib.rs +++ b/python/pecos-rslib/src/lib.rs @@ -1,4 +1,16 @@ -#![allow(clippy::needless_pass_by_value)] // PyO3 requires owned values from Python +// PyO3 binding signatures are constrained by the Python ABI and generated +// method wrappers. Python docstrings also contain Python snippets that Clippy's +// Rust-doc Markdown lint misclassifies. Keep this list limited to binding/docs +// shape lints. +#![allow( + clippy::doc_markdown, + clippy::missing_errors_doc, + clippy::missing_panics_doc, + clippy::needless_pass_by_value, + clippy::too_many_arguments, + clippy::unnecessary_wraps, + clippy::unused_self +)] #![doc(html_root_url = "https://docs.rs/pecos-rslib")] // Disable doctests since they don't work with our workspace setup #![cfg_attr(docsrs, feature(doc_cfg))] @@ -49,6 +61,7 @@ mod phir_json_bridge; mod programs_module; mod py_foreign_decoder; mod py_foreign_simulator; +mod quantum_info_bindings; mod shot_results_bindings; mod sim; mod simulator_utils; @@ -303,6 +316,9 @@ fn pecos_rslib(_py: Python<'_>, m: &Bound<'_, PyModule>) -> PyResult<()> { // Register quantum circuit types (DagCircuit, Gate, GateType, QubitId) dag_circuit_bindings::register_quantum_circuit_types(m)?; + // Register quantum-information channel and measure types + quantum_info_bindings::register_quantum_info_module(m)?; + // Register gate registry types (GateRegistry, GateDefBuilder, AngleSource) gate_registry_bindings::register_gate_registry_types(m)?; diff --git a/python/pecos-rslib/src/pauli_bindings.rs b/python/pecos-rslib/src/pauli_bindings.rs index bd5e22a93..07a685d4c 100644 --- a/python/pecos-rslib/src/pauli_bindings.rs +++ b/python/pecos-rslib/src/pauli_bindings.rs @@ -16,6 +16,11 @@ //! to Python, allowing quantum error models to use native Pauli representations //! instead of string-based arrays. +// pyfunction generates internal modules named after the function (X, Y, Z) +#![allow(non_snake_case)] + +use std::hash::{Hash, Hasher}; + use crate::prelude::{ Pauli as RustPauli, PauliOperator, PauliString as RustPauliString, QuarterPhase, QubitId, }; @@ -201,7 +206,7 @@ impl PauliString { }; // Build PauliString from input - let rust_paulis = if let Some(pauli_input) = paulis { + let mut rust_paulis = if let Some(pauli_input) = paulis { use pyo3::types::PyList; // Try to extract as a list - using cast() per PyO3 0.27 API @@ -247,50 +252,63 @@ impl PauliString { Vec::new() }; + rust_paulis.retain(|(pauli, _)| *pauli != RustPauli::I); + rust_paulis.sort_by_key(|(_, qubit)| *qubit); + for window in rust_paulis.windows(2) { + if window[0].1 == window[1].1 { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "multiple non-identity Pauli operators were specified for qubit {}; \ + use multiplication (*) if you intend to compose Paulis on the same qubit", + window[0].1.index() + ))); + } + } + // Construct RustPauliString using the new constructor let inner = RustPauliString::with_phase_and_paulis(rust_phase, rust_paulis); Ok(PauliString { inner }) } - /// Create `PauliString` from a string like "XYZ" or "IXZI" + /// Create `PauliString` from dense or sparse string notation. /// /// Args: - /// s: String of Pauli operators (I, X, Y, Z) + /// s: Dense notation like "XIZ" or sparse notation like "X0 Z2". /// /// Returns: - /// `PauliString` with operators on sequential qubits starting at 0 + /// `PauliString` parsed with the same auto-detection as Rust. /// /// Examples: - /// >>> ps = `PauliString.from_str("XYZ`") + /// >>> ps = PauliString.from_str("XYZ") /// >>> # X on qubit 0, Y on qubit 1, Z on qubit 2 + /// >>> ps = PauliString.from_str("X0 Z2") + /// >>> # X on qubit 0, Z on qubit 2 #[staticmethod] fn from_str(s: &str) -> PyResult { - // Parse string character by character - let mut paulis = Vec::new(); - - for (i, c) in s.chars().enumerate() { - let pauli = match c { - 'I' | 'i' => RustPauli::I, - 'X' | 'x' => RustPauli::X, - 'Y' | 'y' => RustPauli::Y, - 'Z' | 'z' => RustPauli::Z, - _ => { - return Err(pyo3::exceptions::PyValueError::new_err(format!( - "Invalid Pauli character '{c}' at position {i}. Must be 'I', 'X', 'Y', or 'Z'" - ))); - } - }; - - // Only store non-identity operators (sparse representation) - if pauli != RustPauli::I { - paulis.push((pauli, QubitId::new(i))); - } - } + s.parse::() + .map(|inner| PauliString { inner }) + .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string())) + } - let inner = RustPauliString::with_phase_and_paulis(QuarterPhase::PlusOne, paulis); + /// Create `PauliString` from dense string notation. + /// + /// Dense notation uses one Pauli character per qubit index, e.g. "XIZ" + /// means X on qubit 0 and Z on qubit 2. + #[staticmethod] + fn from_dense_str(s: &str) -> PyResult { + RustPauliString::from_dense_str(s) + .map(|inner| PauliString { inner }) + .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string())) + } - Ok(PauliString { inner }) + /// Create `PauliString` from sparse string notation. + /// + /// Sparse notation uses Pauli/qubit tokens, e.g. "X0 Z2". + #[staticmethod] + fn from_sparse_str(s: &str) -> PyResult { + RustPauliString::from_sparse_str(s) + .map(|inner| PauliString { inner }) + .map_err(|err| pyo3::exceptions::PyValueError::new_err(err.to_string())) } /// String representation @@ -326,6 +344,21 @@ impl PauliString { format!("{phase_str}{pauli_str}") } + /// Dense string representation with an explicit phase prefix. + /// + /// Example: `+XIZ` for X on qubit 0 and Z on qubit 2. + #[pyo3(signature = (num_qubits=None))] + fn to_dense_str(&self, num_qubits: Option) -> String { + self.inner.to_dense_str(num_qubits) + } + + /// Sparse string representation with an explicit phase prefix. + /// + /// Example: `+X0 Z2` for X on qubit 0 and Z on qubit 2. + fn to_sparse_str(&self) -> String { + self.inner.to_sparse_str() + } + /// Repr for debugging fn __repr__(&self) -> String { let phase = self.get_phase(); @@ -419,11 +452,155 @@ impl PauliString { fn anticommutes_with(&self, other: &PauliString) -> bool { !self.inner.commutes_with(&other.inner) } + + // ---- Single-qubit constructors ---- + + /// Create X on a single qubit: `PauliString.X(3)` + #[staticmethod] + #[allow(non_snake_case)] + fn X(qubit: usize) -> Self { + PauliString { + inner: RustPauliString::x(qubit), + } + } + + /// Create Y on a single qubit: `PauliString.Y(3)` + #[staticmethod] + #[allow(non_snake_case)] + fn Y(qubit: usize) -> Self { + PauliString { + inner: RustPauliString::y(qubit), + } + } + + /// Create Z on a single qubit: `PauliString.Z(3)` + #[staticmethod] + #[allow(non_snake_case)] + fn Z(qubit: usize) -> Self { + PauliString { + inner: RustPauliString::z(qubit), + } + } + + /// Create identity: `PauliString.I()` + #[staticmethod] + #[allow(non_snake_case)] + fn I() -> Self { + PauliString { + inner: RustPauliString::identity(), + } + } + + // ---- Tensor product operator (&) ---- + + /// Tensor product: `PauliString.X(0) & PauliString.Z(1)` + fn __and__(&self, other: &PauliString) -> PyResult { + let mut lhs_qubits = self.inner.qubits(); + lhs_qubits.sort_unstable(); + lhs_qubits.dedup(); + + let mut rhs_qubits = other.inner.qubits(); + rhs_qubits.sort_unstable(); + rhs_qubits.dedup(); + + let mut overlap = Vec::new(); + let mut lhs_idx = 0; + let mut rhs_idx = 0; + while lhs_idx < lhs_qubits.len() && rhs_idx < rhs_qubits.len() { + match lhs_qubits[lhs_idx].cmp(&rhs_qubits[rhs_idx]) { + std::cmp::Ordering::Less => lhs_idx += 1, + std::cmp::Ordering::Greater => rhs_idx += 1, + std::cmp::Ordering::Equal => { + overlap.push(lhs_qubits[lhs_idx]); + lhs_idx += 1; + rhs_idx += 1; + } + } + } + + if !overlap.is_empty() { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "tensor product requires disjoint Pauli support; overlapping qubits: {overlap:?}" + ))); + } + + Ok(PauliString { + inner: self.inner.clone() & other.inner.clone(), + }) + } + + /// Pauli multiplication: `PauliString.X(0) * PauliString.Y(0)` + fn __mul__(&self, other: &PauliString) -> Self { + PauliString { + inner: self.inner.clone() * other.inner.clone(), + } + } + + /// Negation: `-PauliString.X(0)` + fn __neg__(&self) -> Self { + PauliString { + inner: -self.inner.clone(), + } + } + + /// Equality check. + fn __eq__(&self, other: &PauliString) -> bool { + self.inner == other.inner + } + + /// Hash for use in dictionaries and sets. + fn __hash__(&self) -> u64 { + let mut hasher = std::collections::hash_map::DefaultHasher::new(); + self.inner.hash(&mut hasher); + hasher.finish() + } + + /// Number of non-identity Pauli operators. + fn weight(&self) -> usize { + self.inner.weight() + } + + /// List of qubit indices with non-identity operators. + fn qubits(&self) -> Vec { + self.inner.qubits() + } +} + +// Module-level constructor functions for `from pecos import X, Y, Z` + +/// Create a single-qubit X `PauliString`: `X(3)` +#[pyfunction] +#[allow(non_snake_case)] +pub fn X(qubit: usize) -> PauliString { + PauliString { + inner: RustPauliString::x(qubit), + } +} + +/// Create a single-qubit Y `PauliString`: `Y(3)` +#[pyfunction] +#[allow(non_snake_case)] +pub fn Y(qubit: usize) -> PauliString { + PauliString { + inner: RustPauliString::y(qubit), + } +} + +/// Create a single-qubit Z `PauliString`: `Z(3)` +#[pyfunction] +#[allow(non_snake_case)] +pub fn Z(qubit: usize) -> PauliString { + PauliString { + inner: RustPauliString::z(qubit), + } } /// Register Pauli types with Python module pub fn register_pauli_types(m: &Bound<'_, PyModule>) -> PyResult<()> { m.add_class::()?; m.add_class::()?; + m.add_function(pyo3::wrap_pyfunction!(X, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(Y, m)?)?; + m.add_function(pyo3::wrap_pyfunction!(Z, m)?)?; Ok(()) } diff --git a/python/pecos-rslib/src/pauli_sequence_bindings.rs b/python/pecos-rslib/src/pauli_sequence_bindings.rs index 4fad77cee..f6cf9cf3d 100644 --- a/python/pecos-rslib/src/pauli_sequence_bindings.rs +++ b/python/pecos-rslib/src/pauli_sequence_bindings.rs @@ -126,9 +126,18 @@ impl PyPauliSequence { self.inner.is_abelian() } - /// Commutation matrix: result[i][j] is True if i and j commute. - fn commutation_matrix(&self) -> Vec> { - self.inner.commutation_matrix() + /// Anticommutation matrix: result[i][j] is 1 if i and j anticommute. + fn commutation_matrix(&self) -> Vec> { + self.inner.commutation_matrix().rows() + } + + /// Greedily partition Pauli strings into mutually commuting groups. + fn group_commuting(&self) -> Vec { + self.inner + .group_commuting() + .into_iter() + .map(|inner| Self { inner }) + .collect() } /// Row-reduced form: independent Pauli strings in echelon form. diff --git a/python/pecos-rslib/src/quantum_info_bindings.rs b/python/pecos-rslib/src/quantum_info_bindings.rs new file mode 100644 index 000000000..a4ffc5958 --- /dev/null +++ b/python/pecos-rslib/src/quantum_info_bindings.rs @@ -0,0 +1,943 @@ +// 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. + +//! Thin Python bindings for PECOS quantum-information primitives. + +use std::collections::BTreeMap; + +use crate::pauli_bindings::PauliString as PyPauliString; +use nalgebra::{DMatrix, DVector}; +use num_complex::Complex64; +use pecos_core::{Pauli as RustPauli, PauliBitmaskSmall, QuarterPhase}; +use pecos_quantum::{ + ChiMatrix as RustChiMatrix, ChoiMatrix as RustChoiMatrix, KrausOps as RustKrausOps, + PauliChannel as RustPauliChannel, ProcessTomographyDesign as RustProcessTomographyDesign, + Ptm as RustPtm, Stinespring as RustStinespring, SuperOp as RustSuperOp, + average_gate_fidelity as rust_average_gate_fidelity, entropy as rust_entropy, + gate_error as rust_gate_error, hellinger_distance as rust_hellinger_distance, + hellinger_fidelity as rust_hellinger_fidelity, + logarithmic_negativity as rust_logarithmic_negativity, negativity as rust_negativity, + partial_trace_qubits as rust_partial_trace_qubits, + partial_trace_subsystems as rust_partial_trace_subsystems, pauli_basis_len, + process_fidelity as rust_process_fidelity, purity as rust_purity, + random_density_matrix as rust_random_density_matrix, + random_quantum_channel as rust_random_quantum_channel, state_fidelity as rust_state_fidelity, + state_fidelity_with_density_matrix as rust_state_fidelity_with_density_matrix, +}; +use pecos_random::PecosRng; +use pyo3::prelude::*; +use pyo3::types::{PyDict, PyModule, PyTuple}; + +type PySchmidtTerm = (f64, Vec, Vec); + +fn py_value_err(err: impl std::fmt::Display) -> PyErr { + pyo3::exceptions::PyValueError::new_err(err.to_string()) +} + +fn real_matrix_from_rows(rows: Vec>) -> PyResult> { + let row_count = rows.len(); + let col_count = rows.first().map_or(0, Vec::len); + if rows.iter().any(|row| row.len() != col_count) { + return Err(pyo3::exceptions::PyValueError::new_err( + "matrix rows must all have the same length", + )); + } + let data: Vec = rows.into_iter().flatten().collect(); + Ok(DMatrix::from_row_slice(row_count, col_count, &data)) +} + +fn complex_matrix_from_rows(rows: Vec>) -> PyResult> { + let row_count = rows.len(); + let col_count = rows.first().map_or(0, Vec::len); + if rows.iter().any(|row| row.len() != col_count) { + return Err(pyo3::exceptions::PyValueError::new_err( + "matrix rows must all have the same length", + )); + } + let data: Vec = rows.into_iter().flatten().collect(); + Ok(DMatrix::from_row_slice(row_count, col_count, &data)) +} + +fn complex_matrices_from_rows( + matrices: Vec>>, +) -> PyResult>> { + matrices.into_iter().map(complex_matrix_from_rows).collect() +} + +fn real_matrix_to_rows(matrix: &DMatrix) -> Vec> { + (0..matrix.nrows()) + .map(|row| (0..matrix.ncols()).map(|col| matrix[(row, col)]).collect()) + .collect() +} + +fn complex_matrix_to_rows(matrix: &DMatrix) -> Vec> { + (0..matrix.nrows()) + .map(|row| (0..matrix.ncols()).map(|col| matrix[(row, col)]).collect()) + .collect() +} + +fn complex_matrices_to_rows(matrices: &[DMatrix]) -> Vec>> { + matrices.iter().map(complex_matrix_to_rows).collect() +} + +fn parse_pauli_label(num_qubits: usize, label: &str) -> PyResult { + let label = label.trim(); + if label.len() != num_qubits { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "Pauli label '{label}' has length {}, expected {num_qubits}", + label.len() + ))); + } + let mut index = 0usize; + for (qubit, ch) in label.chars().rev().enumerate() { + let digit = match ch { + 'I' => 0, + 'X' => 1, + 'Y' => 2, + 'Z' => 3, + _ => { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "invalid Pauli label '{label}'; expected only I, X, Y, Z" + ))); + } + }; + index |= digit << (2 * qubit); + } + pecos_quantum::basis_bitmask(num_qubits, index).map_err(py_value_err) +} + +fn parse_pauli_string_key( + num_qubits: usize, + pauli_string: &PyPauliString, +) -> PyResult { + if pauli_string.inner.get_phase() != QuarterPhase::PlusOne { + return Err(pyo3::exceptions::PyValueError::new_err( + "PauliChannel probabilities require unphased PauliString keys", + )); + } + + let mut out = PauliBitmaskSmall::identity(); + for (pauli, qubit) in pauli_string.inner.get_paulis() { + let qubit = qubit.index(); + if qubit >= num_qubits { + return Err(pyo3::exceptions::PyValueError::new_err(format!( + "PauliString key acts on qubit {qubit}, outside num_qubits={num_qubits}" + ))); + } + let single = match pauli { + RustPauli::I => PauliBitmaskSmall::identity(), + RustPauli::X => PauliBitmaskSmall::x(qubit), + RustPauli::Y => PauliBitmaskSmall::y(qubit), + RustPauli::Z => PauliBitmaskSmall::z(qubit), + }; + out = out.multiply(&single); + } + Ok(out) +} + +fn parse_pauli_probability_key( + num_qubits: usize, + key: &Bound<'_, PyAny>, +) -> PyResult { + if let Ok(label) = key.extract::() { + return parse_pauli_label(num_qubits, &label); + } + if let Ok(pauli_string) = key.extract::>() { + return parse_pauli_string_key(num_qubits, &pauli_string); + } + Err(pyo3::exceptions::PyTypeError::new_err( + "PauliChannel probability keys must be dense Pauli labels or PauliString objects", + )) +} + +fn insert_probability( + probabilities: &mut BTreeMap, + pauli: PauliBitmaskSmall, + probability: f64, +) -> PyResult<()> { + if probabilities.insert(pauli, probability).is_some() { + return Err(pyo3::exceptions::PyValueError::new_err( + "duplicate PauliChannel probability key", + )); + } + Ok(()) +} + +fn pauli_probabilities_from_py( + num_qubits: usize, + probabilities: &Bound<'_, PyAny>, +) -> PyResult> { + let mut out = BTreeMap::new(); + if let Ok(dict) = probabilities.cast::() { + for (key, value) in dict.iter() { + let pauli = parse_pauli_probability_key(num_qubits, &key)?; + insert_probability(&mut out, pauli, value.extract()?)?; + } + } else { + for item in probabilities.try_iter()? { + let tuple: Bound<'_, PyTuple> = item?.cast_into()?; + if tuple.len() != 2 { + return Err(pyo3::exceptions::PyValueError::new_err( + "PauliChannel probability sequences must contain (pauli, probability) pairs", + )); + } + let pauli = parse_pauli_probability_key(num_qubits, &tuple.get_item(0)?)?; + insert_probability(&mut out, pauli, tuple.get_item(1)?.extract()?)?; + } + } + Ok(out) +} + +#[pyclass(name = "PauliChannel", module = "pecos_rslib.quantum_info")] +pub struct PyPauliChannel { + inner: RustPauliChannel, +} + +#[pymethods] +impl PyPauliChannel { + #[staticmethod] + fn one_qubit(px: f64, py: f64, pz: f64) -> PyResult { + Ok(Self { + inner: RustPauliChannel::one_qubit(px, py, pz).map_err(py_value_err)?, + }) + } + + #[staticmethod] + fn from_probabilities(num_qubits: usize, probabilities: &Bound<'_, PyAny>) -> PyResult { + Ok(Self { + inner: RustPauliChannel::try_new( + num_qubits, + pauli_probabilities_from_py(num_qubits, probabilities)?, + ) + .map_err(py_value_err)?, + }) + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn probabilities(&self) -> PyResult> { + let mut out = BTreeMap::new(); + let basis_len = pauli_basis_len(self.inner.num_qubits()).map_err(py_value_err)?; + for basis_index in 0..basis_len { + let pauli = pecos_quantum::basis_bitmask(self.inner.num_qubits(), basis_index) + .map_err(py_value_err)?; + let probability = self.inner.probability(&pauli); + if probability > 0.0 { + out.insert( + pecos_quantum::basis_label(self.inner.num_qubits(), basis_index) + .map_err(py_value_err)?, + probability, + ); + } + } + Ok(out) + } + + fn total_error_rate(&self) -> f64 { + self.inner.total_error_rate() + } + + fn to_ptm(&self) -> PyResult { + Ok(PyPtm { + inner: self.inner.to_ptm().map_err(py_value_err)?, + }) + } + + fn __repr__(&self) -> String { + format!("PauliChannel(num_qubits={})", self.inner.num_qubits()) + } +} + +#[pyclass(name = "Ptm", module = "pecos_rslib.quantum_info")] +pub struct PyPtm { + inner: RustPtm, +} + +#[pymethods] +impl PyPtm { + #[new] + fn new(num_qubits: usize, matrix: Vec>) -> PyResult { + Ok(Self { + inner: RustPtm::try_new(num_qubits, real_matrix_from_rows(matrix)?) + .map_err(py_value_err)?, + }) + } + + #[staticmethod] + fn identity(num_qubits: usize) -> PyResult { + Ok(Self { + inner: RustPtm::identity(num_qubits).map_err(py_value_err)?, + }) + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn matrix(&self) -> Vec> { + real_matrix_to_rows(self.inner.matrix()) + } + + fn entry(&self, output: usize, input: usize) -> f64 { + self.inner.entry(output, input) + } + + fn to_choi(&self) -> PyResult { + Ok(PyChoiMatrix { + inner: self.inner.to_choi().map_err(py_value_err)?, + }) + } + + fn to_kraus(&self) -> PyResult { + Ok(PyKrausOps { + inner: self.inner.to_kraus().map_err(py_value_err)?, + }) + } + + fn to_superop(&self) -> PyResult { + Ok(PySuperOp { + inner: self.inner.to_superop().map_err(py_value_err)?, + }) + } + + fn to_chi(&self) -> PyResult { + Ok(PyChiMatrix { + inner: self.inner.to_chi().map_err(py_value_err)?, + }) + } + + fn __repr__(&self) -> String { + format!("Ptm(num_qubits={})", self.inner.num_qubits()) + } +} + +#[pyclass(name = "KrausOps", module = "pecos_rslib.quantum_info")] +pub struct PyKrausOps { + inner: RustKrausOps, +} + +#[pymethods] +impl PyKrausOps { + #[new] + fn new(num_qubits: usize, operators: Vec>>) -> PyResult { + let operators = operators + .into_iter() + .map(complex_matrix_from_rows) + .collect::>>()?; + Ok(Self { + inner: RustKrausOps::try_new(num_qubits, operators).map_err(py_value_err)?, + }) + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn operators(&self) -> Vec>> { + self.inner + .operators() + .iter() + .map(complex_matrix_to_rows) + .collect() + } + + fn is_trace_preserving(&self) -> bool { + self.inner.is_trace_preserving() + } + + fn to_ptm(&self) -> PyResult { + Ok(PyPtm { + inner: self.inner.to_ptm().map_err(py_value_err)?, + }) + } + + fn to_choi(&self) -> PyResult { + Ok(PyChoiMatrix { + inner: self.inner.to_choi().map_err(py_value_err)?, + }) + } + + fn to_superop(&self) -> PyResult { + Ok(PySuperOp { + inner: self.inner.to_superop().map_err(py_value_err)?, + }) + } + + fn to_chi(&self) -> PyResult { + Ok(PyChiMatrix { + inner: self.inner.to_chi().map_err(py_value_err)?, + }) + } + + fn to_stinespring(&self) -> PyResult { + Ok(PyStinespring { + inner: self.inner.to_stinespring().map_err(py_value_err)?, + }) + } + + fn __repr__(&self) -> String { + format!("KrausOps(num_qubits={})", self.inner.num_qubits()) + } +} + +#[pyclass(name = "ChoiMatrix", module = "pecos_rslib.quantum_info")] +pub struct PyChoiMatrix { + inner: RustChoiMatrix, +} + +#[pymethods] +impl PyChoiMatrix { + #[new] + fn new(num_qubits: usize, matrix: Vec>) -> PyResult { + Ok(Self { + inner: RustChoiMatrix::try_new(num_qubits, complex_matrix_from_rows(matrix)?) + .map_err(py_value_err)?, + }) + } + + #[staticmethod] + fn from_matrix_unit_outputs( + num_qubits: usize, + outputs: Vec>>, + ) -> PyResult { + Ok(Self { + inner: RustChoiMatrix::from_matrix_unit_outputs( + num_qubits, + &complex_matrices_from_rows(outputs)?, + ) + .map_err(py_value_err)?, + }) + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn matrix(&self) -> Vec> { + complex_matrix_to_rows(self.inner.matrix()) + } + + fn apply_to_operator(&self, operator: Vec>) -> PyResult>> { + Ok(complex_matrix_to_rows( + &self + .inner + .apply_to_operator(&complex_matrix_from_rows(operator)?) + .map_err(py_value_err)?, + )) + } + + fn partial_trace_output(&self) -> PyResult>> { + Ok(complex_matrix_to_rows( + &self.inner.partial_trace_output().map_err(py_value_err)?, + )) + } + + fn partial_trace_input(&self) -> PyResult>> { + Ok(complex_matrix_to_rows( + &self.inner.partial_trace_input().map_err(py_value_err)?, + )) + } + + fn is_completely_positive(&self) -> bool { + self.inner.is_completely_positive() + } + + fn is_trace_preserving(&self) -> bool { + self.inner.is_trace_preserving() + } + + fn is_cptp(&self) -> bool { + self.inner.is_cptp() + } + + fn is_unital(&self) -> bool { + self.inner.is_unital() + } + + fn to_ptm(&self) -> PyResult { + Ok(PyPtm { + inner: self.inner.to_ptm().map_err(py_value_err)?, + }) + } + + fn to_kraus(&self) -> PyResult { + Ok(PyKrausOps { + inner: self.inner.to_kraus().map_err(py_value_err)?, + }) + } + + fn to_superop(&self) -> PyResult { + Ok(PySuperOp { + inner: self.inner.to_superop().map_err(py_value_err)?, + }) + } + + fn to_chi(&self) -> PyResult { + Ok(PyChiMatrix { + inner: self.inner.to_chi().map_err(py_value_err)?, + }) + } + + fn __repr__(&self) -> String { + format!("ChoiMatrix(num_qubits={})", self.inner.num_qubits()) + } +} + +#[pyclass(name = "ProcessTomographyDesign", module = "pecos_rslib.quantum_info")] +pub struct PyProcessTomographyDesign { + inner: RustProcessTomographyDesign, +} + +#[pymethods] +impl PyProcessTomographyDesign { + #[staticmethod] + fn matrix_unit(num_qubits: usize) -> PyResult { + Ok(Self { + inner: RustProcessTomographyDesign::matrix_unit(num_qubits).map_err(py_value_err)?, + }) + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn dim(&self) -> usize { + self.inner.dim() + } + + fn num_inputs(&self) -> usize { + self.inner.num_inputs() + } + + fn input_index(&self, row: usize, col: usize) -> PyResult { + self.inner.input_index(row, col).map_err(py_value_err) + } + + fn input_metadata(&self, index: usize) -> PyResult<(usize, usize, usize)> { + let input = self.inner.input_metadata(index).map_err(py_value_err)?; + Ok((input.index, input.row, input.col)) + } + + fn input_metadata_all(&self) -> Vec<(usize, usize, usize)> { + self.inner + .input_metadata_all() + .into_iter() + .map(|input| (input.index, input.row, input.col)) + .collect() + } + + fn input_operator(&self, index: usize) -> PyResult>> { + Ok(complex_matrix_to_rows( + &self.inner.input_operator(index).map_err(py_value_err)?, + )) + } + + fn input_operators(&self) -> Vec>> { + complex_matrices_to_rows(&self.inner.input_operators()) + } + + fn simulate_outputs(&self, channel: &PyChoiMatrix) -> PyResult>>> { + Ok(complex_matrices_to_rows( + &self + .inner + .simulate_outputs(&channel.inner) + .map_err(py_value_err)?, + )) + } + + fn reconstruct_choi(&self, outputs: Vec>>) -> PyResult { + Ok(PyChoiMatrix { + inner: self + .inner + .reconstruct_choi(&complex_matrices_from_rows(outputs)?) + .map_err(py_value_err)?, + }) + } + + fn __repr__(&self) -> String { + format!( + "ProcessTomographyDesign(num_qubits={}, num_inputs={})", + self.inner.num_qubits(), + self.inner.num_inputs() + ) + } +} + +#[pyclass(name = "SuperOp", module = "pecos_rslib.quantum_info")] +pub struct PySuperOp { + inner: RustSuperOp, +} + +#[pymethods] +impl PySuperOp { + #[new] + fn new(num_qubits: usize, matrix: Vec>) -> PyResult { + Ok(Self { + inner: RustSuperOp::try_new(num_qubits, complex_matrix_from_rows(matrix)?) + .map_err(py_value_err)?, + }) + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn matrix(&self) -> Vec> { + complex_matrix_to_rows(self.inner.matrix()) + } + + fn to_choi(&self) -> PyResult { + Ok(PyChoiMatrix { + inner: self.inner.to_choi().map_err(py_value_err)?, + }) + } + + fn to_ptm(&self) -> PyResult { + Ok(PyPtm { + inner: self.inner.to_ptm().map_err(py_value_err)?, + }) + } + + fn to_kraus(&self) -> PyResult { + Ok(PyKrausOps { + inner: self.inner.to_kraus().map_err(py_value_err)?, + }) + } + + fn compose(&self, other: &PySuperOp) -> PyResult { + Ok(PySuperOp { + inner: self.inner.compose(&other.inner).map_err(py_value_err)?, + }) + } + + fn tensor(&self, other: &PySuperOp) -> PyResult { + Ok(PySuperOp { + inner: self.inner.tensor(&other.inner).map_err(py_value_err)?, + }) + } + + fn __repr__(&self) -> String { + format!("SuperOp(num_qubits={})", self.inner.num_qubits()) + } +} + +#[pyclass(name = "ChiMatrix", module = "pecos_rslib.quantum_info")] +pub struct PyChiMatrix { + inner: RustChiMatrix, +} + +#[pymethods] +impl PyChiMatrix { + #[new] + fn new(num_qubits: usize, matrix: Vec>) -> PyResult { + Ok(Self { + inner: RustChiMatrix::try_new(num_qubits, complex_matrix_from_rows(matrix)?) + .map_err(py_value_err)?, + }) + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn matrix(&self) -> Vec> { + complex_matrix_to_rows(self.inner.matrix()) + } + + fn to_choi(&self) -> PyResult { + Ok(PyChoiMatrix { + inner: self.inner.to_choi().map_err(py_value_err)?, + }) + } + + fn to_ptm(&self) -> PyResult { + Ok(PyPtm { + inner: self.inner.to_ptm().map_err(py_value_err)?, + }) + } + + fn __repr__(&self) -> String { + format!("ChiMatrix(num_qubits={})", self.inner.num_qubits()) + } +} + +#[pyclass(name = "Stinespring", module = "pecos_rslib.quantum_info")] +pub struct PyStinespring { + inner: RustStinespring, +} + +#[pymethods] +impl PyStinespring { + #[new] + fn new(num_qubits: usize, isometry: Vec>) -> PyResult { + Ok(Self { + inner: RustStinespring::try_new(num_qubits, complex_matrix_from_rows(isometry)?) + .map_err(py_value_err)?, + }) + } + + fn num_qubits(&self) -> usize { + self.inner.num_qubits() + } + + fn environment_dim(&self) -> usize { + self.inner.environment_dim() + } + + fn isometry(&self) -> Vec> { + complex_matrix_to_rows(self.inner.isometry()) + } + + fn to_kraus(&self) -> PyResult { + Ok(PyKrausOps { + inner: self.inner.to_kraus().map_err(py_value_err)?, + }) + } + + fn to_choi(&self) -> PyResult { + Ok(PyChoiMatrix { + inner: self.inner.to_choi().map_err(py_value_err)?, + }) + } + + fn to_superop(&self) -> PyResult { + Ok(PySuperOp { + inner: self.inner.to_superop().map_err(py_value_err)?, + }) + } + + fn __repr__(&self) -> String { + format!( + "Stinespring(num_qubits={}, environment_dim={})", + self.inner.num_qubits(), + self.inner.environment_dim() + ) + } +} + +#[pyfunction] +fn state_fidelity(left: Vec, right: Vec) -> PyResult { + rust_state_fidelity(&DVector::from_vec(left), &DVector::from_vec(right)).map_err(py_value_err) +} + +#[pyfunction] +fn state_fidelity_with_density_matrix( + rho: Vec>, + psi: Vec, +) -> PyResult { + rust_state_fidelity_with_density_matrix( + &complex_matrix_from_rows(rho)?, + &DVector::from_vec(psi), + ) + .map_err(py_value_err) +} + +#[pyfunction] +fn purity(rho: Vec>) -> PyResult { + rust_purity(&complex_matrix_from_rows(rho)?).map_err(py_value_err) +} + +#[pyfunction] +fn entropy(rho: Vec>) -> PyResult { + rust_entropy(&complex_matrix_from_rows(rho)?).map_err(py_value_err) +} + +#[pyfunction] +fn shannon_entropy(probabilities: Vec, base: f64) -> PyResult { + pecos_quantum::shannon_entropy(&probabilities, base).map_err(py_value_err) +} + +#[pyfunction] +fn negativity(rho: Vec>, dims: Vec, subsystem: usize) -> PyResult { + rust_negativity(&complex_matrix_from_rows(rho)?, &dims, subsystem).map_err(py_value_err) +} + +#[pyfunction] +fn logarithmic_negativity( + rho: Vec>, + dims: Vec, + subsystem: usize, +) -> PyResult { + rust_logarithmic_negativity(&complex_matrix_from_rows(rho)?, &dims, subsystem) + .map_err(py_value_err) +} + +#[pyfunction] +fn schmidt_decomposition( + state: Vec, + dims: Vec, + left_subsystems: Vec, +) -> PyResult> { + pecos_quantum::schmidt_decomposition(&DVector::from_vec(state), &dims, &left_subsystems) + .map_err(py_value_err) +} + +#[pyfunction] +fn partial_trace_subsystems( + rho: Vec>, + dims: Vec, + traced_subsystems: Vec, +) -> PyResult>> { + Ok(complex_matrix_to_rows( + &rust_partial_trace_subsystems(&complex_matrix_from_rows(rho)?, &dims, &traced_subsystems) + .map_err(py_value_err)?, + )) +} + +#[pyfunction] +fn partial_trace_qubits( + rho: Vec>, + num_qubits: usize, + traced_qubits: Vec, +) -> PyResult>> { + Ok(complex_matrix_to_rows( + &rust_partial_trace_qubits(&complex_matrix_from_rows(rho)?, num_qubits, &traced_qubits) + .map_err(py_value_err)?, + )) +} + +#[pyfunction] +fn hellinger_distance(left: Vec, right: Vec) -> PyResult { + rust_hellinger_distance(&left, &right).map_err(py_value_err) +} + +#[pyfunction] +fn hellinger_fidelity(left: Vec, right: Vec) -> PyResult { + rust_hellinger_fidelity(&left, &right).map_err(py_value_err) +} + +#[pyfunction] +fn process_fidelity(left: &PyPtm, right: &PyPtm) -> PyResult { + rust_process_fidelity(&left.inner, &right.inner).map_err(py_value_err) +} + +#[pyfunction] +fn average_gate_fidelity(left: &PyPtm, right: &PyPtm) -> PyResult { + rust_average_gate_fidelity(&left.inner, &right.inner).map_err(py_value_err) +} + +#[pyfunction] +fn gate_error(left: &PyPtm, right: &PyPtm) -> PyResult { + rust_gate_error(&left.inner, &right.inner).map_err(py_value_err) +} + +#[pyfunction] +fn pauli_channel_diamond_norm(left: &PyPauliChannel, right: &PyPauliChannel) -> PyResult { + pecos_quantum::pauli_channel_diamond_norm(&left.inner, &right.inner).map_err(py_value_err) +} + +#[pyfunction] +fn pauli_channel_diamond_distance(left: &PyPauliChannel, right: &PyPauliChannel) -> PyResult { + pecos_quantum::pauli_channel_diamond_distance(&left.inner, &right.inner).map_err(py_value_err) +} + +#[pyfunction] +fn matrix_unit_basis(num_qubits: usize) -> PyResult>>> { + Ok(complex_matrices_to_rows( + &pecos_quantum::matrix_unit_basis(num_qubits).map_err(py_value_err)?, + )) +} + +#[pyfunction] +fn random_density_matrix(num_qubits: usize, seed: u64) -> PyResult>> { + let mut rng = PecosRng::seed_from_u64(seed); + Ok(complex_matrix_to_rows( + &rust_random_density_matrix(&mut rng, num_qubits).map_err(py_value_err)?, + )) +} + +#[pyfunction] +fn random_quantum_channel(num_qubits: usize, num_kraus: usize, seed: u64) -> PyResult { + let mut rng = PecosRng::seed_from_u64(seed); + Ok(PyKrausOps { + inner: rust_random_quantum_channel(&mut rng, num_qubits, num_kraus) + .map_err(py_value_err)?, + }) +} + +pub fn register_quantum_info_module(parent: &Bound<'_, PyModule>) -> PyResult<()> { + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + parent.add_class::()?; + + parent.add_function(wrap_pyfunction!(state_fidelity, parent)?)?; + parent.add_function(wrap_pyfunction!( + state_fidelity_with_density_matrix, + parent + )?)?; + parent.add_function(wrap_pyfunction!(purity, parent)?)?; + parent.add_function(wrap_pyfunction!(entropy, parent)?)?; + parent.add_function(wrap_pyfunction!(shannon_entropy, parent)?)?; + parent.add_function(wrap_pyfunction!(negativity, parent)?)?; + parent.add_function(wrap_pyfunction!(logarithmic_negativity, parent)?)?; + parent.add_function(wrap_pyfunction!(schmidt_decomposition, parent)?)?; + parent.add_function(wrap_pyfunction!(partial_trace_subsystems, parent)?)?; + parent.add_function(wrap_pyfunction!(partial_trace_qubits, parent)?)?; + parent.add_function(wrap_pyfunction!(hellinger_distance, parent)?)?; + parent.add_function(wrap_pyfunction!(hellinger_fidelity, parent)?)?; + parent.add_function(wrap_pyfunction!(process_fidelity, parent)?)?; + parent.add_function(wrap_pyfunction!(average_gate_fidelity, parent)?)?; + parent.add_function(wrap_pyfunction!(gate_error, parent)?)?; + parent.add_function(wrap_pyfunction!(pauli_channel_diamond_norm, parent)?)?; + parent.add_function(wrap_pyfunction!(pauli_channel_diamond_distance, parent)?)?; + parent.add_function(wrap_pyfunction!(matrix_unit_basis, parent)?)?; + parent.add_function(wrap_pyfunction!(random_density_matrix, parent)?)?; + parent.add_function(wrap_pyfunction!(random_quantum_channel, parent)?)?; + + let py = parent.py(); + let module = PyModule::new(py, "quantum_info")?; + for name in [ + "PauliChannel", + "Ptm", + "KrausOps", + "ChoiMatrix", + "ProcessTomographyDesign", + "SuperOp", + "ChiMatrix", + "Stinespring", + "state_fidelity", + "state_fidelity_with_density_matrix", + "purity", + "entropy", + "shannon_entropy", + "negativity", + "logarithmic_negativity", + "schmidt_decomposition", + "partial_trace_subsystems", + "partial_trace_qubits", + "hellinger_distance", + "hellinger_fidelity", + "process_fidelity", + "average_gate_fidelity", + "gate_error", + "pauli_channel_diamond_norm", + "pauli_channel_diamond_distance", + "matrix_unit_basis", + "random_density_matrix", + "random_quantum_channel", + ] { + module.add(name, parent.getattr(name)?)?; + } + + let sys = py.import("sys")?; + let modules = sys.getattr("modules")?; + modules.set_item("pecos_rslib.quantum_info", &module)?; + parent.add_submodule(&module)?; + Ok(()) +} diff --git a/python/quantum-pecos/src/pecos/__init__.py b/python/quantum-pecos/src/pecos/__init__.py index e36ede4ed..c7c7f7af9 100644 --- a/python/quantum-pecos/src/pecos/__init__.py +++ b/python/quantum-pecos/src/pecos/__init__.py @@ -47,6 +47,9 @@ ShotVec, # Simulation result: vector of shots TimeUnits, # Abstract time duration in arbitrary units WasmForeignObject, # WASM foreign object for classical coprocessor + X, # Single-qubit Pauli X constructor: X(qubit) -> PauliString + Y, # Single-qubit Pauli Y constructor: Y(qubit) -> PauliString + Z, # Single-qubit Pauli Z constructor: Z(qubit) -> PauliString abs, # Absolute value acos, # Inverse cosine acosh, # Inverse hyperbolic cosine @@ -281,8 +284,8 @@ def __getattr__(name: str): biased_depolarizing_noise = pecos_rslib.biased_depolarizing_noise general_noise = pecos_rslib.general_noise state_vector = pecos_rslib.state_vector -sparse_stab = pecos_rslib.sparse_stab stabilizer = pecos_rslib.stabilizer +sparse_stab = pecos_rslib.sparse_stab stab_vec = pecos_rslib.stab_vec density_matrix = pecos_rslib.density_matrix hugr_engine = pecos_rslib.hugr_engine @@ -338,6 +341,9 @@ def __getattr__(name: str): "WasmError", "WasmForeignObject", "Wat", + "X", + "Y", + "Z", "__version__", "abs", "acos", diff --git a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py index 19ef17633..ae03fb7bf 100644 --- a/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py +++ b/python/quantum-pecos/src/pecos/circuits/quantum_circuit.py @@ -159,7 +159,7 @@ def active_qudits(self) -> list[set[int]]: if tick is not None: # Get individual qubits from all gates in the tick active: set[int] = set() - for gate in tick.gates(): + for gate in tick.gate_batches(): for q in gate.qubits: active.add(q) result.append(active) @@ -380,7 +380,7 @@ def _iter_tick( # Use a dict to preserve insertion order and group gates grouped: dict[tuple[str, str], tuple[set[Location], JSONDict]] = {} - for gate_idx, gate in enumerate(tick_obj.gates()): + for gate_idx, gate in enumerate(tick_obj.gate_batches()): # Check for stored original symbol in metadata stored_symbol = tick_obj.get_gate_attr(gate_idx, "_symbol") @@ -769,7 +769,7 @@ def active_qudits(self) -> set[Location]: return set() active: set[Location] = set() - for gate in tick.gates(): + for gate in tick.gate_batches(): qubits = list(gate.qubits) if len(qubits) == 1: active.add(qubits[0]) diff --git a/python/quantum-pecos/src/pecos/guppy/surface.py b/python/quantum-pecos/src/pecos/guppy/surface.py index 7d11e01fc..66883176e 100644 --- a/python/quantum-pecos/src/pecos/guppy/surface.py +++ b/python/quantum-pecos/src/pecos/guppy/surface.py @@ -159,10 +159,25 @@ def generate_guppy_source(patch: "SurfacePatch") -> str: lines.extend(f" h(ax{stab.index})" for stab in geom.x_stabilizers) # Measure ancillas (destructive) + # Each measurement gets a per-measurement result() call that ties the + # physical measurement to a MeasId. The result() names encode the + # stabilizer type and index. The AllocateResult IDs generated by + # these calls flow through the trace and become MeasIds on the TickCircuit. lines.append("") + # Measure ancillas with per-measurement result() identity. + # Tag format: "label:idx" where label is the stabilizer name and idx is the + # round-local measurement index. The global MeasId is assigned by the runtime + # via AllocateResult and flows through the trace automatically. lines.append(" # Measure ancillas") - lines.extend(f" sx{stab.index} = measure(ax{stab.index})" for stab in geom.x_stabilizers) - lines.extend(f" sz{stab.index} = measure(az{stab.index})" for stab in geom.z_stabilizers) + idx = 0 + for stab in geom.x_stabilizers: + 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})") + lines.append(f' result("sz{stab.index}:meas:{idx}", sz{stab.index})') + 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) diff --git a/python/quantum-pecos/src/pecos/noise/depolarizing_error_model.py b/python/quantum-pecos/src/pecos/noise/depolarizing_error_model.py index 9e15a7cb0..9f1c21a0c 100644 --- a/python/quantum-pecos/src/pecos/noise/depolarizing_error_model.py +++ b/python/quantum-pecos/src/pecos/noise/depolarizing_error_model.py @@ -72,7 +72,7 @@ def __init__(self, error_params: dict) -> None: - p1: Single-qubit gate error probability - p2: Two-qubit gate error probability - p_meas: Measurement error probability - - p_init: Initialization error probability + - p_prep: Preparation error probability - scale: Optional scaling factor for all error rates - p1_error_model: Optional custom single-qubit Pauli error distribution - p2_error_model: Optional custom two-qubit Pauli error distribution @@ -125,7 +125,7 @@ def _scale(self) -> None: self._eparams["p_meas"] = pc.mean(self._eparams["p_meas"]) self._eparams["p_meas"] *= scale - self._eparams["p_init"] *= scale + self._eparams["p_prep"] *= scale def shot_reinit(self) -> None: """Run all code needed at the beginning of each shot, e.g., resetting state.""" @@ -159,7 +159,7 @@ def process( elif op.name in {"init |0>", "Init", "Init +Z"}: qops_after = noise_initz_bitflip( op, - p=self._eparams["p_init"], + p=self._eparams["p_prep"], ) # ######################################## diff --git a/python/quantum-pecos/src/pecos/noise/error_depolar.py b/python/quantum-pecos/src/pecos/noise/error_depolar.py index 5d0683db7..b723b9649 100644 --- a/python/quantum-pecos/src/pecos/noise/error_depolar.py +++ b/python/quantum-pecos/src/pecos/noise/error_depolar.py @@ -72,7 +72,7 @@ def scaling(self) -> None: self.error_params["p_meas"] = pc.mean(self.error_params["p_meas"]) self.error_params["p_meas"] *= scale - self.error_params["p_init"] *= scale + self.error_params["p_prep"] *= scale def start( self, @@ -152,7 +152,7 @@ def generate_tick_errors( # INITS WITH X NOISE elif symbol == "init |0>": noisy = set(locations) - self.error_params["noiseless_qubits"] - noise_init_bitflip(noisy, after, "X", p=self.error_params["p_init"]) + noise_init_bitflip(noisy, after, "X", p=self.error_params["p_prep"]) # ######################################## # ONE QUBIT GATES diff --git a/python/quantum-pecos/src/pecos/noise/generic_error_model.py b/python/quantum-pecos/src/pecos/noise/generic_error_model.py index a2ebaaf77..0d892db99 100644 --- a/python/quantum-pecos/src/pecos/noise/generic_error_model.py +++ b/python/quantum-pecos/src/pecos/noise/generic_error_model.py @@ -80,7 +80,7 @@ def __init__(self, error_params: dict) -> None: - p1: Single-qubit gate error probability - p2: Two-qubit gate error probability - p_meas: Measurement error probability - - p_init: Initialization error probability + - p_prep: Preparation error probability - scale: Optional scaling factor for all error rates - p1_error_model: Optional custom single-qubit Pauli error distribution - p2_error_model: Optional custom two-qubit Pauli error distribution @@ -134,7 +134,7 @@ def _scale(self) -> None: self._eparams["p_meas"] = pc.mean(self._eparams["p_meas"]) self._eparams["p_meas"] *= scale - self._eparams["p_init"] *= scale + self._eparams["p_prep"] *= scale def shot_reinit(self) -> None: """Run all code needed at the beginning of each shot, e.g., resetting state.""" @@ -165,7 +165,7 @@ def process( if op.name in {"init |0>", "Init", "Init +Z"}: qops_after = noise_initz_bitflip_leakage( op, - p=self._eparams["p_init"], + p=self._eparams["p_prep"], machine=self.machine, ) diff --git a/python/quantum-pecos/src/pecos/noise/simple_depolarizing_error_model.py b/python/quantum-pecos/src/pecos/noise/simple_depolarizing_error_model.py index a739f8822..4569c4677 100644 --- a/python/quantum-pecos/src/pecos/noise/simple_depolarizing_error_model.py +++ b/python/quantum-pecos/src/pecos/noise/simple_depolarizing_error_model.py @@ -62,7 +62,7 @@ def __init__(self, error_params: dict) -> None: - p1: Single-qubit gate error probability - p2: Two-qubit gate error probability - p_meas: Measurement error probability - - p_init: Initialization error probability + - p_prep: Initialization error probability """ self.error_params = dict(error_params) self.machine = None @@ -127,7 +127,7 @@ def process( # Use fused operation to check and get error indices in one pass error_indices = pc.random.compare_indices( len(op.args), - self._eparams["p_init"], + self._eparams["p_prep"], ) for idx in error_indices: diff --git a/python/quantum-pecos/src/pecos/qec/__init__.py b/python/quantum-pecos/src/pecos/qec/__init__.py index 3288d2966..362efa148 100644 --- a/python/quantum-pecos/src/pecos/qec/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/__init__.py @@ -38,9 +38,6 @@ EquivalenceResult, FaultLocation, InfluenceBuilder, - MeasurementNoiseModel, - MemBuilder, - NoisySampler, ParsedDem, assert_dems_equivalent, compare_dems_exact, @@ -50,6 +47,15 @@ from pecos.qec import analysis, color, protocols, surface from pecos.qec.analysis import ( + build_adaptive_dem, + compare_flip_matrices, + compare_k_body_rates, + detector_flip_matrices_by_round, + detector_flip_matrix, + detector_k_body_rates, + detector_k_body_rates_by_round, + empirical_correlation_table, + fit_dem_from_simulation, logical_error_rate, logical_fidelity, logical_from_data, @@ -86,12 +92,14 @@ StabilizerSupport, SurfacePatch, SurfacePatchBuilder, + build_memory_circuit, compute_x_stabilizer_supports, compute_z_stabilizer_supports, generate_nonrotated_surface_layout, generate_surface_layout, parity_matrix_x, parity_matrix_z, + surface_code_memory, ) __all__ = [ @@ -110,9 +118,6 @@ "EquivalenceResult", "FaultLocation", "InfluenceBuilder", - "MeasurementNoiseModel", - "MemBuilder", - "NoisySampler", "ParsedDem", "assert_dems_equivalent", "compare_dems_exact", @@ -124,6 +129,15 @@ "PAULI_Y", "PAULI_Z", # Analysis utilities + "build_adaptive_dem", + "compare_flip_matrices", + "compare_k_body_rates", + "detector_flip_matrices_by_round", + "detector_flip_matrix", + "detector_k_body_rates", + "detector_k_body_rates_by_round", + "empirical_correlation_table", + "fit_dem_from_simulation", "logical_error_rate", "logical_fidelity", "logical_from_data", @@ -158,6 +172,8 @@ "StabilizerSupport", "SurfacePatch", "SurfacePatchBuilder", + "build_memory_circuit", + "surface_code_memory", # Color code "ColorCode488", "ColorCode488Builder", diff --git a/python/quantum-pecos/src/pecos/qec/analysis.py b/python/quantum-pecos/src/pecos/qec/analysis.py index b2121afa6..2f22f1f8e 100644 --- a/python/quantum-pecos/src/pecos/qec/analysis.py +++ b/python/quantum-pecos/src/pecos/qec/analysis.py @@ -10,7 +10,9 @@ from __future__ import annotations +import json import math +from itertools import combinations from typing import TYPE_CHECKING if TYPE_CHECKING: @@ -216,3 +218,630 @@ def lower_bound_fidelity(f1: float, f2: float) -> float: Lower bound on true fidelity. """ return (4 / 5) * (f1 + f2) - (3 / 5) + + +# --------------------------------------------------------------------------- +# Detector flip frequency matrices +# --------------------------------------------------------------------------- + + +def detector_flip_matrix( + detector_events: Sequence[Sequence[int]], + num_detectors: int, +) -> list[list[float]]: + """Compute the detector flip frequency matrix from sampled detector events. + + The matrix M has: + - M[i][i] = P(detector i fires) (marginal rate) + - M[i][j] = 0.5 * P(detector i AND j both fire) (half joint rate) + + The factor 0.5 on off-diagonal entries makes the matrix interpretable + as a covariance-like object: each correlated error mechanism contributes + equally to M[i][j] and M[j][i]. + + Args: + detector_events: Per-shot detector outcomes. Each entry is a sequence + of detector indices that fired in that shot. Alternatively, each + entry can be a full-length binary vector (0/1) of length + ``num_detectors``. + num_detectors: Total number of detectors. + + Returns: + ``num_detectors x num_detectors`` matrix as nested lists. + """ + n = num_detectors + shots = len(detector_events) + if shots == 0: + return [[0.0] * n for _ in range(n)] + + inv_shots = 1.0 / shots + half_inv = 0.5 * inv_shots + + # Accumulate as flat list for speed + m = [0.0] * (n * n) + + for events in detector_events: + # Determine which detectors fired + fired = [i for i in range(n) if events[i]] if len(events) == n else list(events) + + for a in fired: + m[a * n + a] += inv_shots # diagonal + for b in fired: + if b > a: + m[a * n + b] += half_inv + m[b * n + a] += half_inv + + return [m[i * n : (i + 1) * n] for i in range(n)] + + +def detector_flip_matrices_by_round( + detector_events: Sequence[Sequence[int]], + num_detectors: int, + detectors_per_round: int, +) -> list[list[list[float]]]: + """Compute per-round detector flip frequency matrices. + + Groups detectors into rounds of ``detectors_per_round`` each and + computes a separate flip frequency matrix for each round. + + Args: + detector_events: Per-shot detector outcomes (same format as + :func:`detector_flip_matrix`). + num_detectors: Total number of detectors. + detectors_per_round: Number of detectors in each syndrome + extraction round. + + Returns: + List of matrices, one per round. + """ + num_rounds = (num_detectors + detectors_per_round - 1) // detectors_per_round + shots = len(detector_events) + if shots == 0: + return [[[0.0] * detectors_per_round for _ in range(detectors_per_round)] for _ in range(num_rounds)] + + inv_shots = 1.0 / shots + half_inv = 0.5 * inv_shots + k = detectors_per_round + + # One flat matrix per round + matrices = [[0.0] * (k * k) for _ in range(num_rounds)] + + for events in detector_events: + fired = [i for i in range(num_detectors) if events[i]] if len(events) == num_detectors else list(events) + + # Bin by round + round_fired: dict[int, list[int]] = {} + for d in fired: + r = d // k + local = d % k + round_fired.setdefault(r, []).append(local) + + for r, local_ids in round_fired.items(): + if r >= num_rounds: + continue + mat = matrices[r] + for a in local_ids: + mat[a * k + a] += inv_shots + for b in local_ids: + if b > a: + mat[a * k + b] += half_inv + mat[b * k + a] += half_inv + + return [[matrices[r][i * k : (i + 1) * k] for i in range(k)] for r in range(num_rounds)] + + +def compare_flip_matrices( + sim_matrix: Sequence[Sequence[float]], + dem_matrix: Sequence[Sequence[float]], + *, + min_rate: float = 0.0005, +) -> tuple[float, float, tuple[int, int]]: + """Compare two detector flip frequency matrices. + + Args: + sim_matrix: Ground truth matrix (from simulation). + dem_matrix: Test matrix (from DEM sampling). + min_rate: Minimum entry value to consider (avoids division by tiny + numbers from statistical noise). + + Returns: + Tuple of ``(max_relative_error, frobenius_relative_error, + worst_element)`` where ``worst_element`` is the ``(i, j)`` index + of the largest relative error. + """ + n = len(sim_matrix) + max_err = 0.0 + worst = (0, 0) + sum_sq_diff = 0.0 + sum_sq_sim = 0.0 + + for i in range(n): + for j in range(n): + s = sim_matrix[i][j] + d = dem_matrix[i][j] + diff = d - s + sum_sq_diff += diff * diff + sum_sq_sim += s * s + if s > min_rate: + rel = abs(diff / s) + if rel > max_err: + max_err = rel + worst = (i, j) + + frob_rel = (sum_sq_diff**0.5) / max(sum_sq_sim**0.5, 1e-30) + return max_err, frob_rel, worst + + +# --------------------------------------------------------------------------- +# Higher-order detector correlation analysis +# --------------------------------------------------------------------------- + + +def detector_k_body_rates( + detector_events: Sequence[Sequence[int]], + num_detectors: int, + max_order: int = 3, +) -> dict[tuple[int, ...], float]: + """Compute k-body detector firing rates up to a given order. + + For each subset of detectors of size 1..max_order that fires together + in at least one shot, records the joint firing probability. + + - 1-body: ``(i,)`` -> P(Di fires) + - 2-body: ``(i, j)`` -> P(Di AND Dj fire) + - 3-body: ``(i, j, k)`` -> P(Di AND Dj AND Dk fire) + + Keys are sorted tuples of detector indices. + + Args: + detector_events: Per-shot detector outcomes. Each entry is either + a list of fired detector indices or a full binary vector. + num_detectors: Total number of detectors. + max_order: Maximum correlation order (default 3). + + Returns: + Dict mapping detector index tuples to joint firing rates. + """ + shots = len(detector_events) + if shots == 0: + return {} + + inv_shots = 1.0 / shots + rates: dict[tuple[int, ...], float] = {} + + for events in detector_events: + fired = [i for i in range(num_detectors) if events[i]] if len(events) == num_detectors else sorted(events) + + for k in range(1, min(max_order, len(fired)) + 1): + for combo in combinations(fired, k): + if combo in rates: + rates[combo] += inv_shots + else: + rates[combo] = inv_shots + + return rates + + +def detector_k_body_rates_by_round( + detector_events: Sequence[Sequence[int]], + num_detectors: int, + detectors_per_round: int, + max_order: int = 3, +) -> list[dict[tuple[int, ...], float]]: + """Compute per-round k-body detector firing rates. + + Groups detectors into rounds, then computes k-body rates within + each round. Detector indices in the returned dicts are round-local + (0..detectors_per_round-1). + + Args: + detector_events: Per-shot detector outcomes. + num_detectors: Total number of detectors. + detectors_per_round: Number of detectors per round. + max_order: Maximum correlation order (default 3). + + Returns: + List of dicts, one per round, mapping local detector index + tuples to joint firing rates. + """ + k = detectors_per_round + num_rounds = (num_detectors + k - 1) // k + shots = len(detector_events) + if shots == 0: + return [{} for _ in range(num_rounds)] + + inv_shots = 1.0 / shots + round_rates: list[dict[tuple[int, ...], float]] = [{} for _ in range(num_rounds)] + + for events in detector_events: + fired = [i for i in range(num_detectors) if events[i]] if len(events) == num_detectors else sorted(events) + + # Bin fired detectors by round + round_fired: dict[int, list[int]] = {} + for d in fired: + r = d // k + local = d % k + round_fired.setdefault(r, []).append(local) + + for r, local_ids in round_fired.items(): + if r >= num_rounds: + continue + rr = round_rates[r] + for order in range(1, min(max_order, len(local_ids)) + 1): + for combo in combinations(local_ids, order): + if combo in rr: + rr[combo] += inv_shots + else: + rr[combo] = inv_shots + + return round_rates + + +def compare_k_body_rates( + sim_rates: dict[tuple[int, ...], float], + dem_rates: dict[tuple[int, ...], float], + *, + min_rate: float = 0.0005, + max_order: int | None = None, +) -> dict[int, tuple[float, float, tuple[int, ...]]]: + """Compare k-body rates between simulation and DEM, grouped by order. + + Args: + sim_rates: Ground truth rates from simulation. + dem_rates: Rates from DEM sampling. + min_rate: Minimum rate to consider for relative error. + max_order: If set, only compare up to this order. + + Returns: + Dict mapping order k to ``(max_relative_error, + rms_relative_error, worst_event)`` for that order. + """ + # Collect all keys + all_keys = set(sim_rates.keys()) | set(dem_rates.keys()) + + # Group by order + by_order: dict[int, list[tuple[tuple[int, ...], float, float]]] = {} + for key in all_keys: + k = len(key) + if max_order is not None and k > max_order: + continue + s = sim_rates.get(key, 0.0) + d = dem_rates.get(key, 0.0) + by_order.setdefault(k, []).append((key, s, d)) + + result: dict[int, tuple[float, float, tuple[int, ...]]] = {} + for k in sorted(by_order.keys()): + entries = by_order[k] + max_err = 0.0 + worst: tuple[int, ...] = () + sum_sq_rel = 0.0 + count = 0 + + for key, s, d in entries: + if s > min_rate: + rel = abs(d / s - 1) + if rel > max_err: + max_err = rel + worst = key + sum_sq_rel += rel * rel + count += 1 + + rms = (sum_sq_rel / max(count, 1)) ** 0.5 + result[k] = (max_err, rms, worst) + + return result + + +# --------------------------------------------------------------------------- +# Simulation-based correlation table +# --------------------------------------------------------------------------- + + +def empirical_correlation_table( + tick_circuit: object, + noise_builder: object, + shots: int, + max_order: int = 2, + *, + backend: str = "stabilizer", + seed: int = 42, +) -> list[tuple[tuple[int, ...], float]]: + """Build an empirical correlation table from simulation. + + Runs ``sim_neo`` with the given noise model, extracts detector events + per shot, and computes k-body joint detection rates. Same output format + as :func:`exact_correlation_table` from the Heisenberg walk. + + Args: + tick_circuit: A ``TickCircuit`` with detector metadata. + noise_builder: A noise builder (e.g., ``depolarizing().p1(0.001).p2(0.01)``). + shots: Number of simulation shots. + max_order: Maximum correlation order (1 = marginals, 2 = pairwise, etc.). + backend: Simulator backend — ``"stabilizer"``, ``"statevec"``, + or ``"meas_sampling"``. The meas_sampling backend uses the fast + whole-circuit DEM-based sampler (geometric/O(fired)) instead of + gate-by-gate simulation. + seed: RNG seed. + + Returns: + List of ``(detector_indices_tuple, probability)`` pairs, same format + as ``exact_correlation_table``. + + Example:: + + from pecos_rslib_exp import depolarizing + from pecos.qec.analysis import empirical_correlation_table + + table = empirical_correlation_table( + tc, depolarizing().p1(0.001).p2(0.01), shots=100000, max_order=3, + ) + for indices, prob in table: + print(f"P({indices}) = {prob:.6f}") + """ + from pecos_rslib_exp import ( + meas_sampling, + sim_neo, + stabilizer, + statevec, + ) + + if backend == "meas_sampling": + results = sim_neo(tick_circuit).quantum(meas_sampling()).noise(noise_builder).shots(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() + else: + supported = "'stabilizer', 'statevec', 'meas_sampling'" + msg = f"Unknown backend {backend!r}. Supported: {supported}." + raise ValueError( + msg, + ) + + det_json = json.loads(tick_circuit.get_meta("detectors")) + obs_json_str = tick_circuit.get_meta("observables") + obs_json = json.loads(obs_json_str) if obs_json_str else [] + num_meas = int(tick_circuit.get_meta("num_measurements")) + len(det_json) + + # Extract fired detectors and observables per shot + fired_per_shot: list[list[int]] = [] + obs_per_shot: list[list[int]] = [] + for r in results: + meas = list(r) + fired: list[int] = [] + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + fired.append(i) + fired_per_shot.append(fired) + + obs_fired: list[int] = [] + for i, obs in enumerate(obs_json): + val = 0 + for rec in obs["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + obs_fired.append(i) + obs_per_shot.append(obs_fired) + + # Compute detector k-body rates with string labels + inv_shots = 1.0 / shots + rates: dict[tuple[str, ...], float] = {} + + for fired in fired_per_shot: + labels = [f"D{d}" for d in fired] + for k in range(1, min(max_order, len(labels)) + 1): + for combo in combinations(labels, k): + rates[combo] = rates.get(combo, 0.0) + inv_shots + + # Observable marginals: P(Lj) + for obs_fired in obs_per_shot: + for o in obs_fired: + key = (f"L{o}",) + rates[key] = rates.get(key, 0.0) + inv_shots + + # Detector-observable pairwise: P(Di AND Lj) + for fired, obs_fired in zip(fired_per_shot, obs_per_shot, strict=False): + for d in fired: + for o in obs_fired: + key = (f"D{d}", f"L{o}") + rates[key] = rates.get(key, 0.0) + inv_shots + + return sorted(rates.items()) + + +def fit_dem_from_simulation( + tick_circuit: object, + noise_builder: object, + shots: int, + *, + backend: str = "stabilizer", + seed: int = 42, + max_correlation_order: int = 2, +) -> str: + """Build a DEM fitted to simulation data. + + Runs simulation to get empirical detection rates, uses the circuit's + mechanism structure, and fits mechanism probabilities to match the + empirical marginals and pairwise rates. Returns a Stim DEM string. + + This is the "hardware calibration" workflow: real noise statistics + (or accurate simulation) combined with circuit-derived structure. + + Args: + tick_circuit: A ``TickCircuit`` with detector metadata. + noise_builder: A noise builder (e.g., ``depolarizing().p1(0.001)``). + shots: Number of simulation shots. + backend: Simulator backend — ``"stabilizer"``, ``"statevec"``, + or ``"meas_sampling"``. The meas_sampling backend uses the fast + whole-circuit DEM-based sampler instead of gate-by-gate simulation. + seed: RNG seed. + max_correlation_order: Max order for empirical rates (1 or 2). + + Returns: + Stim-format DEM string with simulation-fitted probabilities. + """ + if max_correlation_order < 1: + msg = "max_correlation_order must be at least 1" + raise ValueError(msg) + + from pecos_rslib.qec import ( + DagFaultAnalyzer, + DemBuilder, + fit_dem_to_marginals, + mechanisms_to_dem_string, + ) + from pecos_rslib_exp import ( + meas_sampling, + sim_neo, + stabilizer, + statevec, + ) + + # Step 1: Get mechanism structure from circuit + dag = tick_circuit.to_dag_circuit() + analyzer = DagFaultAnalyzer(dag) + influence = analyzer.build_influence_map() + # Use dummy noise to get mechanism structure (probabilities will be replaced) + builder = DemBuilder(influence) + builder = builder.with_noise(p1=0.01, p2=0.01, p_meas=0.01, p_prep=0.01) + builder = builder.with_detectors_json(tick_circuit.get_meta("detectors")) + builder = builder.with_observables_json(tick_circuit.get_meta("observables")) + builder = builder.with_num_measurements( + int(tick_circuit.get_meta("num_measurements")), + ) + dem = builder.build() + dem_str = dem.to_string() + + mechs: list[tuple[float, list[int], list[int]]] = [] + for raw_line in dem_str.strip().split("\n"): + line = raw_line.strip() + if line.startswith("error("): + pe = line.index(")") + prob = float(line[6:pe]) + toks = line[pe + 1 :].split() + ds = sorted(int(t[1:]) for t in toks if t.startswith("D")) + os = sorted(int(t[1:]) for t in toks if t.startswith("L")) + mechs.append((prob, ds, os)) + + # Step 2: Run simulation and extract empirical rates + det_json = json.loads(tick_circuit.get_meta("detectors")) + num_meas = int(tick_circuit.get_meta("num_measurements")) + 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() + 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() + else: + supported = "'stabilizer', 'statevec', 'meas_sampling'" + msg = f"Unknown backend {backend!r}. Supported: {supported}." + raise ValueError(msg) + + inv_shots = 1.0 / shots + emp_marginals = [0.0] * num_dets + for r in results: + meas = list(r) + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(meas): + val ^= meas[idx] + if val: + emp_marginals[i] += inv_shots + + # Step 3: Fit mechanism probabilities to empirical marginals + fitted, _residuals = fit_dem_to_marginals(mechs, emp_marginals) + + return mechanisms_to_dem_string(fitted) + + +def build_adaptive_dem( + tick_circuit: object, + noise_params: dict[str, float], + *, + max_order: int = 2, + prune: float = 1e-12, +) -> tuple[str, str]: + """Build the best DEM using adaptive mechanism structure. + + Uses influence map mechanisms for stochastic noise sources and EEG + backward extraction mechanisms for coherent (idle_rz) noise sources. + Fits all mechanism probabilities to Heisenberg exact marginals + pairwise. + + Args: + tick_circuit: A ``TickCircuit`` with detector metadata. + noise_params: Dict with keys p1, p2, p_meas, p_prep, idle_rz. + max_order: Correlation order for Heisenberg targets (default 2). + prune: Pruning threshold for Heisenberg walks. + + Returns: + Tuple of (json_str, dem_str) — noise characterization JSON and + Stim DEM string. + """ + idle_rz = noise_params.get("idle_rz", 0.0) + + if idle_rz == 0.0 or idle_rz < 1e-10: + # Pure stochastic: from_circuit is best + from pecos_rslib.qec import DemSampler + + DemSampler.from_circuit( + tick_circuit, + p1=noise_params.get("p1", 0.0), + p2=noise_params.get("p2", 0.0), + p_meas=noise_params.get("p_meas", 0.0), + p_prep=noise_params.get("p_prep", 0.0), + ) + # For consistency, also compute correlation table + from pecos_rslib_exp import exact_correlation_table + + table = exact_correlation_table(tick_circuit, **noise_params, max_order=max_order) + # Return a minimal JSON with correlations + json_out = json.dumps( + { + "correlations": [{"nodes": list(labels), "probability": prob} for labels, prob in table], + }, + indent=2, + ) + # Get DEM string from the sampler's internal DEM + dem_str = "" # from_circuit doesn't expose DEM string directly + # Rebuild via DemBuilder + from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder + + dag = tick_circuit.to_dag_circuit() + analyzer = DagFaultAnalyzer(dag) + influence = analyzer.build_influence_map() + builder = DemBuilder(influence) + builder = builder.with_noise( + p1=noise_params.get("p1", 0.0), + p2=noise_params.get("p2", 0.0), + p_meas=noise_params.get("p_meas", 0.0), + p_prep=noise_params.get("p_prep", 0.0), + ) + builder = builder.with_detectors_json(tick_circuit.get_meta("detectors")) + builder = builder.with_observables_json(tick_circuit.get_meta("observables")) + builder = builder.with_num_measurements( + int(tick_circuit.get_meta("num_measurements")), + ) + dem = builder.build() + dem_str = dem.to_string() + + return json_out, dem_str + + # Has coherent noise: use noise_characterization (EEG structure + L-BFGS fit) + from pecos_rslib_exp import noise_characterization + + return noise_characterization( + tick_circuit, + **noise_params, + max_order=max_order, + prune=prune, + ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/__init__.py b/python/quantum-pecos/src/pecos/qec/surface/__init__.py index caf89931b..fb702c3f1 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/__init__.py +++ b/python/quantum-pecos/src/pecos/qec/surface/__init__.py @@ -19,12 +19,12 @@ # Circuit generation from geometry (unified abstraction) from pecos.qec.surface.circuit_builder import ( - CircuitOp, DagCircuitRenderer, GuppyRenderer, OpType, QubitAllocation, StimRenderer, + SurfaceCircuitStep, TickCircuitRenderer, build_surface_code_circuit, classify_stabilizer_boundary, @@ -56,6 +56,7 @@ NoiseModel, SimulationResult, SurfaceDecoder, + build_memory_circuit, build_native_sampler, build_stim_circuit_from_patch, generate_circuit_level_dem, @@ -63,6 +64,7 @@ generate_repetition_code_dem, generate_surface_code_dem, run_noisy_memory_experiment, + surface_code_memory, syndromes_to_detection_events, ) from pecos.qec.surface.layouts import ( @@ -76,6 +78,12 @@ get_rotated_logical_x, get_rotated_logical_z, ) +from pecos.qec.surface.logical_circuit import ( + LogicalCircuitBuilder, + LogicalGateType, + LogicalOp, + PatchState, +) from pecos.qec.surface.parity import ( parity_matrix_x, parity_matrix_z, @@ -134,6 +142,7 @@ "NoiseModel", "SimulationResult", "SurfaceDecoder", + "build_memory_circuit", "build_native_sampler", "build_stim_circuit_from_patch", "generate_circuit_level_dem", @@ -141,12 +150,13 @@ "generate_repetition_code_dem", "generate_surface_code_dem", "run_noisy_memory_experiment", + "surface_code_memory", "syndromes_to_detection_events", # Visualization "plot_patch", "plot_surface_code", # Circuit generation (unified abstraction) - "CircuitOp", + "SurfaceCircuitStep", "DagCircuitRenderer", "GuppyRenderer", "OpType", @@ -171,5 +181,10 @@ "get_stabilizer_schedule_entries", "get_stabilizer_schedule_metadata", "get_stabilizer_touch_label", + # Logical circuit builder (transversal gates) + "LogicalCircuitBuilder", + "LogicalGateType", + "LogicalOp", + "PatchState", "tick_circuit_to_stim", ] 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 6f4ef4e67..14ce63767 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_builder.py @@ -107,8 +107,8 @@ class OpType(Enum): @dataclass -class CircuitOp: - """A circuit operation.""" +class SurfaceCircuitStep: + """A surface-code circuit builder step.""" op_type: OpType qubits: list[int] = field(default_factory=list) @@ -167,7 +167,7 @@ def build_surface_code_circuit( num_rounds: int, basis: str = "Z", ancilla_budget: int | None = None, -) -> tuple[list[CircuitOp], QubitAllocation]: +) -> tuple[list[SurfaceCircuitStep], QubitAllocation]: """Build abstract circuit operations for a surface code memory experiment. This generates the circuit structure matching the Guppy implementation: @@ -235,44 +235,44 @@ def z_anc_q(stab_idx: int) -> int: # Get CNOT schedule cnot_rounds = compute_cnot_schedule(patch) - ops: list[CircuitOp] = [] + ops: list[SurfaceCircuitStep] = [] # ========================================================================= # prep_z_basis / prep_x_basis # ========================================================================= - ops.append(CircuitOp(OpType.COMMENT, label=f"prep_{basis.lower()}_basis")) + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"prep_{basis.lower()}_basis")) # Allocate and reset data qubits - ops.extend(CircuitOp(OpType.ALLOC, [data_q(i)], f"data[{i}]") for i in range(num_data)) + ops.extend(SurfaceCircuitStep(OpType.ALLOC, [data_q(i)], f"data[{i}]") for i in range(num_data)) # For X-basis: H on each data qubit if basis.upper() == "X": - ops.extend(CircuitOp(OpType.H, [data_q(i)]) for i in range(num_data)) + ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) - ops.append(CircuitOp(OpType.TICK)) + ops.append(SurfaceCircuitStep(OpType.TICK)) # ========================================================================= # syndrome_extraction (called num_rounds times) # ========================================================================= for rnd in range(num_rounds): ops.append( - CircuitOp(OpType.COMMENT, label=f"syndrome_extraction round {rnd + 1}"), + SurfaceCircuitStep(OpType.COMMENT, label=f"syndrome_extraction round {rnd + 1}"), ) if effective_ancilla_budget == total_ancilla: - ops.extend(CircuitOp(OpType.ALLOC, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) - ops.extend(CircuitOp(OpType.ALLOC, [z_anc_q(s.index)], f"az{s.index}") for s in geom.z_stabilizers) + 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(CircuitOp(OpType.COMMENT, label="Hadamard on X ancillas")) - ops.extend(CircuitOp(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) + 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 geom.x_stabilizers) - ops.append(CircuitOp(OpType.TICK)) + ops.append(SurfaceCircuitStep(OpType.TICK)) for rnd_idx, cx_round in enumerate(cnot_rounds): - ops.append(CircuitOp(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + 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 == "X": ops.append( - CircuitOp( + SurfaceCircuitStep( OpType.CX, [x_anc_q(stab_idx), data_q(data_idx)], f"X{stab_idx}", @@ -280,26 +280,30 @@ def z_anc_q(stab_idx: int) -> int: ) else: ops.append( - CircuitOp( + SurfaceCircuitStep( OpType.CX, [data_q(data_idx), z_anc_q(stab_idx)], f"Z{stab_idx}", ), ) - ops.append(CircuitOp(OpType.TICK)) + ops.append(SurfaceCircuitStep(OpType.TICK)) - ops.append(CircuitOp(OpType.COMMENT, label="Hadamard on X ancillas")) - ops.extend(CircuitOp(OpType.H, [x_anc_q(s.index)], f"ax{s.index}") for s in geom.x_stabilizers) + 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 geom.x_stabilizers) - ops.append(CircuitOp(OpType.COMMENT, label="Measure ancillas")) - ops.extend(CircuitOp(OpType.MEASURE, [x_anc_q(s.index)], f"sx{s.index}") for s in geom.x_stabilizers) - ops.extend(CircuitOp(OpType.MEASURE, [z_anc_q(s.index)], f"sz{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(CircuitOp(OpType.TICK)) + ops.append(SurfaceCircuitStep(OpType.TICK)) else: stabilizer_batches = _batched_stabilizers(patch, effective_ancilla_budget) for batch in stabilizer_batches: - ops.append(CircuitOp(OpType.COMMENT, label="Prepare ancillas")) + 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 batch @@ -307,7 +311,7 @@ def z_anc_q(stab_idx: int) -> int: for stab_type, stab_idx in batch: ops.append( - CircuitOp( + SurfaceCircuitStep( OpType.ALLOC, [batch_ancillas[(stab_type, stab_idx)]], f"a{stab_type.lower()}{stab_idx}", @@ -316,23 +320,23 @@ def z_anc_q(stab_idx: int) -> int: x_stabilizers_in_batch = [stab_idx for stab_type, stab_idx in batch if stab_type == "X"] if x_stabilizers_in_batch: - ops.append(CircuitOp(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) ops.extend( - CircuitOp(OpType.H, [batch_ancillas[("X", stab_idx)]], f"ax{stab_idx}") + SurfaceCircuitStep(OpType.H, [batch_ancillas[("X", stab_idx)]], f"ax{stab_idx}") for stab_idx in x_stabilizers_in_batch ) - ops.append(CircuitOp(OpType.TICK)) + ops.append(SurfaceCircuitStep(OpType.TICK)) for rnd_idx, cx_round in enumerate(cnot_rounds): - ops.append(CircuitOp(OpType.COMMENT, label=f"CX round {rnd_idx + 1}")) + 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( - CircuitOp( + SurfaceCircuitStep( OpType.CX, [ancilla_q, data_q(data_idx)], f"X{stab_idx}", @@ -340,54 +344,56 @@ def z_anc_q(stab_idx: int) -> int: ) else: ops.append( - CircuitOp( + SurfaceCircuitStep( OpType.CX, [data_q(data_idx), ancilla_q], f"Z{stab_idx}", ), ) - ops.append(CircuitOp(OpType.TICK)) + ops.append(SurfaceCircuitStep(OpType.TICK)) if x_stabilizers_in_batch: - ops.append(CircuitOp(OpType.COMMENT, label="Hadamard on X ancillas")) + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Hadamard on X ancillas")) ops.extend( - CircuitOp(OpType.H, [batch_ancillas[("X", stab_idx)]], f"ax{stab_idx}") + SurfaceCircuitStep(OpType.H, [batch_ancillas[("X", stab_idx)]], f"ax{stab_idx}") for stab_idx in x_stabilizers_in_batch ) - ops.append(CircuitOp(OpType.COMMENT, label="Measure ancillas")) + ops.append(SurfaceCircuitStep(OpType.COMMENT, label="Measure ancillas")) for stab_type, stab_idx in batch: measure_label = f"sx{stab_idx}" if stab_type == "X" else f"sz{stab_idx}" ops.append( - CircuitOp( + SurfaceCircuitStep( OpType.MEASURE, [batch_ancillas[(stab_type, stab_idx)]], measure_label, ), ) - ops.append(CircuitOp(OpType.TICK)) + ops.append(SurfaceCircuitStep(OpType.TICK)) # ========================================================================= # measure_z_basis / measure_x_basis # ========================================================================= - ops.append(CircuitOp(OpType.COMMENT, label=f"measure_{basis.lower()}_basis")) + ops.append(SurfaceCircuitStep(OpType.COMMENT, label=f"measure_{basis.lower()}_basis")) # For X-basis: H on each data qubit first if basis.upper() == "X": - ops.extend(CircuitOp(OpType.H, [data_q(i)]) for i in range(num_data)) + ops.extend(SurfaceCircuitStep(OpType.H, [data_q(i)]) for i in range(num_data)) # Measure all data qubits - ops.extend(CircuitOp(OpType.MEASURE, [data_q(i)], f"final[{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, allocation -def classify_stabilizer_boundary(stab_type: str, data_qubits: tuple[int, ...], d: int) -> str: +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 - return _classify_boundary(stab_type, data_qubits, d) + if dz is None: + dz = d + return _classify_boundary(stab_type, data_qubits, d, dz) def get_stabilizer_region(stab: Stabilizer, patch: SurfacePatch) -> str: @@ -427,7 +433,7 @@ def get_stabilizer_schedule_entries(stab: Stabilizer, patch: SurfacePatch) -> li """Return the per-round touch schedule for one stabilizer.""" from pecos.qec.surface.schedule import get_stab_schedule - schedule = get_stab_schedule(stab.stab_type, stab.data_qubits, stab.is_boundary, patch.distance) + schedule = get_stab_schedule(stab.stab_type, stab.data_qubits, stab.is_boundary, patch.dx, patch.dz) return [ { "round_0based": round_0based, @@ -529,7 +535,7 @@ class CircuitRenderer(ABC): @abstractmethod def render( self, - ops: list[CircuitOp], + ops: list[SurfaceCircuitStep], allocation: QubitAllocation, patch: SurfacePatch, num_rounds: int, @@ -547,7 +553,7 @@ def __init__( p1: float = 0.0, p2: float = 0.0, p_meas: float = 0.0, - p_init: float = 0.0, + p_prep: float = 0.0, add_detectors: bool = True, ) -> None: """Initialize Stim renderer. @@ -556,18 +562,18 @@ def __init__( p1: Single-qubit depolarizing error rate p2: Two-qubit depolarizing error rate p_meas: Measurement error rate - p_init: Initialization error rate + p_prep: Initialization error rate add_detectors: Whether to add DETECTOR annotations """ self.p1 = p1 self.p2 = p2 self.p_meas = p_meas - self.p_init = p_init + self.p_prep = p_prep self.add_detectors = add_detectors def render( self, - ops: list[CircuitOp], + ops: list[SurfaceCircuitStep], allocation: QubitAllocation, patch: SurfacePatch, num_rounds: int, @@ -598,8 +604,8 @@ def render( elif op.op_type == OpType.ALLOC: lines.append(f"R {op.qubits[0]}") - if self.p_init > 0: - lines.append(f"X_ERROR({self.p_init}) {op.qubits[0]}") + if self.p_prep > 0: + lines.append(f"X_ERROR({self.p_prep}) {op.qubits[0]}") elif op.op_type == OpType.H: lines.append(f"H {op.qubits[0]}") @@ -729,7 +735,7 @@ class GuppyRenderer(CircuitRenderer): def render( self, - _ops: list[CircuitOp], + _ops: list[SurfaceCircuitStep], _allocation: QubitAllocation, patch: SurfacePatch, _num_rounds: int, @@ -756,7 +762,7 @@ class DagCircuitRenderer(CircuitRenderer): def render( self, - ops: list[CircuitOp], + ops: list[SurfaceCircuitStep], _allocation: QubitAllocation, _patch: SurfacePatch, _num_rounds: int, @@ -829,7 +835,7 @@ def __init__(self, *, add_detectors: bool = True) -> None: def render( self, - ops: list[CircuitOp], + ops: list[SurfaceCircuitStep], allocation: QubitAllocation, patch: SurfacePatch, num_rounds: int, @@ -874,14 +880,16 @@ def render( # Track measurements for detector annotations meas_count = 0 stab_meas_record: dict[tuple[str, int, int], int] = {} + stab_meas_refs: dict[tuple[str, int, int], list] = {} + final_meas_refs_by_qubit: dict[int, list] = {} current_round = -1 current_phase = "prep" current_cx_round = 0 final_meas_start = 0 - # Store all tick metadata to apply at the end (workaround for metadata - # being lost when new ticks are created) - # Format: {tick_idx: {'phase': str, 'round': int, 'cx_round': int, 'gates': [(label, role), ...]}} + # Store tick-level metadata to apply at the end by tick index. Gate + # metadata is attached immediately as each gate is emitted so it + # participates in TickCircuit's batching decisions. all_tick_metadata: dict[int, dict] = {} def get_stabilizer_from_label(label: str) -> str: @@ -974,7 +982,6 @@ def new_tick() -> TickHandle: "phase": current_phase, "round": current_round, "cx_round": current_cx_round, - "gates": [], } return current_tick_handle @@ -993,24 +1000,34 @@ def mark_qubits_used(qubits: list[int]) -> None: """Mark qubits as used in current tick.""" qubits_in_current_tick.update(qubits) - def queue_gate_metadata(meta: dict | None = None) -> None: - """Queue metadata for the current gate. + def gate_metadata(meta: dict | None = None) -> dict: + """Build metadata for the current gate context. Args: meta: Optional dict with gate metadata (e.g., {"label": "data[0]"}) """ - if current_tick_idx >= 0: - context = { - "phase": current_phase, - } - if current_round >= 0: - context["syndrome_round"] = current_round - if current_cx_round > 0: - context["cx_round"] = current_cx_round - merged_meta = context - if meta: - merged_meta = {**context, **meta} - all_tick_metadata[current_tick_idx]["gates"].append(merged_meta) + context: dict[str, object] = { + "phase": current_phase, + } + if current_round >= 0: + context["syndrome_round"] = current_round + if current_cx_round > 0: + context["cx_round"] = current_cx_round + if meta: + return {**context, **meta} + return context + + def apply_gate_metadata(handle: TickHandle, meta: dict | None = None) -> None: + """Attach metadata to the gate most recently added to a handle.""" + handle.metas(gate_metadata(meta)) + + def apply_measurement_metadata(meas_refs: list, meta: dict | None = None) -> None: + """Attach metadata to the measurement gate just emitted.""" + if not meas_refs: + return + tick_idx, gate_idx, _ = meas_refs[0] + for key, value in gate_metadata(meta).items(): + circuit.set_gate_meta(tick_idx, gate_idx, key, value) for op in ops: if op.op_type == OpType.COMMENT: @@ -1041,88 +1058,91 @@ def queue_gate_metadata(meta: dict | None = None) -> None: tick.qalloc([q]) allocated.add(q) else: - tick.pz([q]) + tick = tick.pz([q]) mark_qubits_used([q]) # Label helps identify which qubit (e.g., "data[0]", "ax0") meta = get_ancilla_gate_metadata(q, op.label) if op.label: meta["label"] = op.label - queue_gate_metadata(meta or None) + apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.PREP: q = op.qubits[0] - get_tick_for_qubits([q]).pz([q]) + tick = get_tick_for_qubits([q]).pz([q]) mark_qubits_used([q]) meta = get_ancilla_gate_metadata(q, op.label) if op.label: meta["label"] = op.label - queue_gate_metadata(meta or None) + apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.H: q = op.qubits[0] - get_tick_for_qubits([q]).h([q]) + tick = get_tick_for_qubits([q]).h([q]) mark_qubits_used([q]) meta = get_ancilla_gate_metadata(q, op.label) if op.label: meta["label"] = op.label - queue_gate_metadata(meta or None) + apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.X: q = op.qubits[0] - get_tick_for_qubits([q]).x([q]) + tick = get_tick_for_qubits([q]).x([q]) mark_qubits_used([q]) meta = get_ancilla_gate_metadata(q, op.label) if op.label: meta["label"] = op.label - queue_gate_metadata(meta or None) + apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.Z: q = op.qubits[0] - get_tick_for_qubits([q]).z([q]) + tick = get_tick_for_qubits([q]).z([q]) mark_qubits_used([q]) meta = get_ancilla_gate_metadata(q, op.label) if op.label: meta["label"] = op.label - queue_gate_metadata(meta or None) + apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.CX: qubits = op.qubits - get_tick_for_qubits(qubits).cx([(qubits[0], qubits[1])]) + 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) if op.label: meta["label"] = op.label - queue_gate_metadata(meta or None) + apply_gate_metadata(tick, meta or None) elif op.op_type == OpType.MEASURE: q = op.qubits[0] - get_tick_for_qubits([q]).mz([q]) + 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) if op.label: meta["label"] = op.label - queue_gate_metadata(meta or None) + apply_measurement_metadata(meas_refs, meta or None) - # Track measurement index for detectors + # Track measurement index and refs for detectors if op.label.startswith("sx"): stab_idx = int(op.label[2:]) stab_meas_record[("X", stab_idx, current_round)] = meas_count + stab_meas_refs[("X", stab_idx, current_round)] = meas_refs elif op.label.startswith("sz"): stab_idx = int(op.label[2:]) stab_meas_record[("Z", stab_idx, current_round)] = meas_count + stab_meas_refs[("Z", stab_idx, current_round)] = meas_refs elif op.label.startswith("final"): if "final[0]" in op.label: final_meas_start = meas_count + # Track all final measurement refs by data qubit + final_meas_refs_by_qubit[q] = meas_refs meas_count += 1 elif op.op_type == OpType.TICK: current_tick_handle = None qubits_in_current_tick = set() - # Apply tick-level and gate-level metadata - # We use the circuit's set_tick_meta and set_gate_meta methods - # which modify the ticks in place (unlike get_tick() which returns a copy) + # 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(): # Set tick-level metadata circuit.set_tick_meta(tick_idx, "phase", tick_meta["phase"]) @@ -1131,12 +1151,6 @@ def queue_gate_metadata(meta: dict | None = None) -> None: if tick_meta["cx_round"] > 0: circuit.set_tick_meta(tick_idx, "cx_round", tick_meta["cx_round"]) - # Set gate-level metadata (only for gates that have meaningful metadata) - for gate_idx, gate_meta in enumerate(tick_meta["gates"]): - if gate_meta: - for key, value in gate_meta.items(): - circuit.set_gate_meta(tick_idx, gate_idx, key, value) - # Add detector annotations as metadata if self.add_detectors: geom = patch.geometry @@ -1241,16 +1255,103 @@ def queue_gate_metadata(meta: dict | None = None) -> None: }, ] - # Store as metadata + # Store as metadata (legacy path for DemBuilder/caching) circuit.set_meta("detectors", json.dumps(detectors)) circuit.set_meta("observables", json.dumps(observables)) 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, + ) circuit.set_meta("basis", basis.upper()) circuit.set_meta("ancilla_budget", str(allocation.total - len(allocation.data_qubits))) return circuit + @staticmethod + def _add_typed_annotations( + circuit: TickCircuit, + geom: object, + num_rounds: int, + basis: str, + stab_meas_refs: dict, + final_meas_refs_by_qubit: dict, + deterministic_type_round0: str, + ) -> None: + """Add typed PauliAnnotation detectors and observables to the circuit. + + This mirrors the JSON detector logic but uses the new annotation API + with TickMeasRef measurement references. + """ + # Syndrome detectors for X stabilizers + for rnd in range(num_rounds): + for s in geom.x_stabilizers: + curr_refs = stab_meas_refs.get(("X", s.index, rnd)) + if curr_refs is None: + continue + if rnd == 0: + if 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), []) + circuit.detector(prev_refs + curr_refs, label=f"Sx{s.index}_r{rnd}") + + # Syndrome detectors for Z stabilizers + for rnd in range(num_rounds): + for s in geom.z_stabilizers: + curr_refs = stab_meas_refs.get(("Z", s.index, rnd)) + if curr_refs is None: + continue + if rnd == 0: + if 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), []) + circuit.detector(prev_refs + curr_refs, label=f"Sz{s.index}_r{rnd}") + + # Final detectors + if basis.upper() == "Z": + stabilizers = geom.z_stabilizers + stab_type = "Z" + logical_qubits = list(geom.logical_z.data_qubits) if geom.logical_z else [] + else: + stabilizers = geom.x_stabilizers + stab_type = "X" + logical_qubits = list(geom.logical_x.data_qubits) if geom.logical_x else [] + + for s in stabilizers: + # Data qubit measurement refs for this stabilizer + data_refs = [] + for dq in s.data_qubits: + if dq in final_meas_refs_by_qubit: + data_refs.extend(final_meas_refs_by_qubit[dq]) + # Last syndrome round ref + last_syn_refs = stab_meas_refs.get( + (stab_type, s.index, num_rounds - 1), + [], + ) + label_prefix = "Sx" if stab_type == "X" else "Sz" + circuit.detector( + data_refs + last_syn_refs, + label=f"{label_prefix}{s.index}_final", + ) + + # Logical observable + obs_refs = [] + for q in logical_qubits: + if q in final_meas_refs_by_qubit: + obs_refs.extend(final_meas_refs_by_qubit[q]) + if obs_refs: + circuit.observable(obs_refs, label=f"logical_{basis.upper()}") + # Convenience functions @@ -1264,7 +1365,7 @@ def generate_stim_from_patch( p1: float = 0.0, p2: float = 0.0, p_meas: float = 0.0, - p_init: float = 0.0, + p_prep: float = 0.0, ) -> str: """Generate Stim circuit from SurfacePatch. @@ -1276,13 +1377,13 @@ def generate_stim_from_patch( p1: Single-qubit error rate p2: Two-qubit error rate p_meas: Measurement error rate - p_init: Initialization error rate + p_prep: Initialization error rate 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_init=p_init) + renderer = StimRenderer(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) return renderer.render(ops, allocation, patch, num_rounds, basis) @@ -1485,7 +1586,7 @@ def tick_circuit_to_stim( p1: float = 0.0, p2: float = 0.0, p_meas: float = 0.0, - p_init: float = 0.0, + p_prep: float = 0.0, ) -> str: """Convert TickCircuit to Stim circuit string. @@ -1497,7 +1598,7 @@ def tick_circuit_to_stim( p1: Single-qubit error rate p2: Two-qubit error rate p_meas: Measurement error rate - p_init: Initialization error rate + p_prep: Initialization error rate Returns: Stim circuit string @@ -1605,7 +1706,7 @@ def _gate_to_stim( for tick_idx in range(tc.num_ticks()): tick = tc.get_tick(tick_idx) - for gate in tick.gates(): + for gate in tick.gate_batches(): instructions, noise_kind = _gate_to_stim(gate) if not instructions: continue @@ -1624,8 +1725,8 @@ def _gate_to_stim( lines.append(f"DEPOLARIZE1({p1}) {qubit_str}") elif noise_kind == "two" and p2 > 0: lines.append(f"DEPOLARIZE2({p2}) {qubit_str}") - elif noise_kind == "prep" and p_init > 0: - lines.append(f"X_ERROR({p_init}) {qubit_str}") + elif noise_kind == "prep" and p_prep > 0: + lines.append(f"X_ERROR({p_prep}) {qubit_str}") # Add TICK after each tick (except the last) if tick_idx < tc.num_ticks() - 1: @@ -1689,7 +1790,7 @@ def generate_dem_from_patch( p1=p, p2=p, p_meas=p, - p_init=p, + p_prep=p, ) circuit = stim.Circuit(circuit_str) return str(circuit.detector_error_model()) @@ -1701,7 +1802,7 @@ def generate_dem_from_tick_circuit_via_pauli_frame( p1: float = 0.01, p2: float = 0.01, p_meas: float = 0.01, - p_init: float = 0.01, + p_prep: float = 0.01, ) -> str: """Generate DEM from TickCircuit using pure Python Pauli frame simulation. @@ -1717,7 +1818,7 @@ def generate_dem_from_tick_circuit_via_pauli_frame( p1: Single-qubit depolarizing error rate p2: Two-qubit depolarizing error rate p_meas: Measurement error rate - p_init: Initialization (prep) error rate + p_prep: Initialization (prep) error rate Returns: DEM string in Stim-compatible format @@ -1760,7 +1861,7 @@ def generate_dem_from_tick_circuit_via_pauli_frame( for tick_idx in range(tc.num_ticks()): tick = tc.get_tick(tick_idx) - for gate in tick.gates(): + for gate in tick.gate_batches(): gate_name = gate.gate_type.name qubits = list(gate.qubits) meas_idx = None @@ -1893,14 +1994,13 @@ def simulate_error( # Process each gate as a potential error location for op_idx, (_tick_idx, gate_name, qubits, meas_idx) in enumerate(circuit_ops): - - if gate_name in ("QAlloc", "PZ") and p_init > 0: + if gate_name in ("QAlloc", "PZ") and p_prep > 0: # Initialization error: X error after prep q = qubits[0] dets, obs = simulate_error(op_idx + 1, {q: "X"}) if dets or obs: key = (frozenset(dets), frozenset(obs)) - error_mechanisms[key] += p_init + error_mechanisms[key] += p_prep elif gate_name == "H" and p1 > 0: # Single-qubit gate error: depolarizing (each Pauli with prob p1/3) @@ -1973,7 +2073,7 @@ def generate_dem_from_tick_circuit_via_stim( p1: float = 0.01, p2: float = 0.01, p_meas: float = 0.01, - p_init: float = 0.01, + p_prep: float = 0.01, decompose_errors: bool = True, maximal_decomposition: bool = False, ) -> str: @@ -1988,7 +2088,7 @@ def generate_dem_from_tick_circuit_via_stim( p1: Single-qubit depolarizing error rate p2: Two-qubit depolarizing error rate p_meas: Measurement error rate - p_init: Initialization (prep) error rate + p_prep: Initialization (prep) error rate decompose_errors: If True (default), ask Stim to decompose hyperedge errors into graphlike components. Set to False to preserve raw hyperedges. @@ -2005,7 +2105,7 @@ 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_init=p_init) + stim_str = tick_circuit_to_stim(tc, p1=p1, 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: @@ -2034,7 +2134,7 @@ def _extract_measurement_order(tc: TickCircuit) -> list[int]: tick = tc.get_tick(tick_idx) if tick is None: continue - gates = tick.gates() + gates = tick.gate_batches() for gate in gates: gate_type = str(gate.gate_type) if "MZ" in gate_type: @@ -2116,7 +2216,10 @@ def generate_dem_from_tick_circuit( p1: float = 0.01, p2: float = 0.01, p_meas: float = 0.01, - p_init: float = 0.01, + p_prep: float = 0.01, + p_idle: float | None = None, + t1: float | None = None, + t2: float | None = None, decompose_errors: bool = True, maximal_decomposition: bool = False, ) -> str: @@ -2143,7 +2246,11 @@ def generate_dem_from_tick_circuit( p1: Single-qubit depolarizing error rate p2: Two-qubit depolarizing error rate p_meas: Measurement error rate - p_init: Initialization (prep) 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. 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. @@ -2178,7 +2285,7 @@ def generate_dem_from_tick_circuit( # Build DEM using Rust DemBuilder builder = DemBuilder(influence_map) - builder.with_noise(p1, p2, p_meas, p_init) + builder.with_noise(p1, p2, p_meas, p_prep, p_idle=p_idle, t1=t1, t2=t2) builder.with_num_measurements(num_measurements) builder.with_measurement_order(measurement_order) builder.with_detectors_json(detectors_json) @@ -2197,12 +2304,12 @@ def generate_dem_from_tick_circuit( def generate_dem_from_tick_circuit_via_autodetection( tc: TickCircuit, *, - logical_z_qubits: list[int] | None = None, - logical_x_qubits: list[int] | None = None, + tracked_z_qubits: list[int] | None = None, + tracked_x_qubits: list[int] | None = None, p1: float = 0.01, p2: float = 0.01, p_meas: float = 0.01, - p_init: float = 0.01, + p_prep: float = 0.01, ) -> str: """Generate DEM from TickCircuit using auto-discovered detectors. @@ -2216,16 +2323,19 @@ def generate_dem_from_tick_circuit_via_autodetection( Args: tc: TickCircuit (detector annotations not required) - logical_z_qubits: Qubit indices for logical Z operator (for X error tracking) - logical_x_qubits: Qubit indices for logical X operator (for Z error tracking) + tracked_z_qubits: Qubit indices for a tracked Z Pauli (for X error tracking) + tracked_x_qubits: Qubit indices for a tracked X Pauli (for Z error tracking) p1: Single-qubit depolarizing error rate p2: Two-qubit depolarizing error rate p_meas: Measurement error rate - p_init: Initialization (prep) error rate + p_prep: Initialization (prep) error rate Returns: - DEM string in Stim-compatible format + PECOS DEM string. With no tracked Paulis this is Stim-compatible; + tracked Paulis are represented with PECOS `pecos_tracked_pauli` + metadata lines. """ + import json from collections import defaultdict from pecos.qec import PAULI_X, PAULI_Y, PAULI_Z, InfluenceBuilder @@ -2235,18 +2345,17 @@ def generate_dem_from_tick_circuit_via_autodetection( # Build influence map with auto-discovered detectors builder = InfluenceBuilder(dag) - if logical_z_qubits: - builder.with_logical_z(logical_z_qubits) - if logical_x_qubits: - builder.with_logical_x(logical_x_qubits) + if tracked_x_qubits: + builder.with_tracked_x(tracked_x_qubits) + if tracked_z_qubits: + builder.with_tracked_z(tracked_z_qubits) influence_map = builder.build() # Get all fault locations and auto-discovered detectors locations = influence_map.get_locations() num_detectors = influence_map.num_detectors - num_logicals = influence_map.num_logicals - # Collect error mechanisms: (detectors, logicals) -> probability + # Collect error mechanisms: (detectors, DEM outputs) -> probability error_mechanisms: dict[tuple[frozenset[int], frozenset[int]], float] = defaultdict( float, ) @@ -2256,23 +2365,23 @@ def generate_dem_from_tick_circuit_via_autodetection( gate_type = loc.gate_type if "PZ" in gate_type or "QAlloc" in gate_type: - if p_init <= 0: + if p_prep <= 0: continue for pauli in [PAULI_X]: dets = set(influence_map.get_detector_indices(loc_idx, pauli)) - logs = set(influence_map.get_logical_indices(loc_idx, pauli)) - if dets or logs: - key = (frozenset(dets), frozenset(logs)) - error_mechanisms[key] += p_init + dem_outputs = set(influence_map.get_observable_indices(loc_idx, pauli)) + if dets or dem_outputs: + key = (frozenset(dets), frozenset(dem_outputs)) + error_mechanisms[key] += p_prep elif "MZ" in gate_type: if p_meas <= 0: continue for pauli in [PAULI_X]: dets = set(influence_map.get_detector_indices(loc_idx, pauli)) - logs = set(influence_map.get_logical_indices(loc_idx, pauli)) - if dets or logs: - key = (frozenset(dets), frozenset(logs)) + dem_outputs = set(influence_map.get_observable_indices(loc_idx, pauli)) + if dets or dem_outputs: + key = (frozenset(dets), frozenset(dem_outputs)) error_mechanisms[key] += p_meas elif "CX" in gate_type: @@ -2280,9 +2389,9 @@ def generate_dem_from_tick_circuit_via_autodetection( continue for pauli in [PAULI_X, PAULI_Y, PAULI_Z]: dets = set(influence_map.get_detector_indices(loc_idx, pauli)) - logs = set(influence_map.get_logical_indices(loc_idx, pauli)) - if dets or logs: - key = (frozenset(dets), frozenset(logs)) + dem_outputs = set(influence_map.get_observable_indices(loc_idx, pauli)) + if dets or dem_outputs: + key = (frozenset(dets), frozenset(dem_outputs)) error_mechanisms[key] += p2 / 3 elif "H" in gate_type: @@ -2290,27 +2399,52 @@ def generate_dem_from_tick_circuit_via_autodetection( continue for pauli in [PAULI_X, PAULI_Y, PAULI_Z]: dets = set(influence_map.get_detector_indices(loc_idx, pauli)) - logs = set(influence_map.get_logical_indices(loc_idx, pauli)) - if dets or logs: - key = (frozenset(dets), frozenset(logs)) + dem_outputs = set(influence_map.get_observable_indices(loc_idx, pauli)) + if dets or dem_outputs: + key = (frozenset(dets), frozenset(dem_outputs)) error_mechanisms[key] += p1 / 3 # Generate DEM output # Add detector declarations (auto-discovered, no coordinates) lines = [f"detector D{det_idx}" for det_idx in range(num_detectors)] - # Add logical observables - lines.extend(f"logical_observable L{log_idx}" for log_idx in range(num_logicals)) + def _pauli_string(pauli: str, qubits: list[int] | None) -> str: + if not qubits: + return "+I" + return "+" + " ".join(f"{pauli}{q}" for q in qubits) + + tracked_pauli_metadata = [] + if tracked_x_qubits: + tracked_pauli_metadata.append( + { + "id": len(tracked_pauli_metadata), + "kind": "tracked_pauli", + "label": "tracked_x", + "pauli": _pauli_string("X", tracked_x_qubits), + }, + ) + if tracked_z_qubits: + tracked_pauli_metadata.append( + { + "id": len(tracked_pauli_metadata), + "kind": "tracked_pauli", + "label": "tracked_z", + "pauli": _pauli_string("Z", tracked_z_qubits), + }, + ) + lines.extend( + f"pecos_tracked_pauli {json.dumps(metadata, separators=(',', ':'))}" for metadata in tracked_pauli_metadata + ) # Add error mechanisms - for (dets, logs), prob in sorted( + for (dets, dem_outputs), prob in sorted( error_mechanisms.items(), key=lambda x: (sorted(x[0][0]), sorted(x[0][1])), ): - if prob > 0 and (dets or logs): + if prob > 0 and (dets or dem_outputs): det_str = " ".join(f"D{d}" for d in sorted(dets)) - log_str = " ".join(f"L{log_idx}" for log_idx in sorted(logs)) - targets = f"{det_str} {log_str}".strip() + dem_output_str = " ".join(f"L{idx}" for idx in sorted(dem_outputs)) + targets = f"{det_str} {dem_output_str}".strip() lines.append(f"error({prob:.6g}) {targets}") return "\n".join(lines) diff --git a/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py b/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py index ff69a0ab6..0886f965e 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py +++ b/python/quantum-pecos/src/pecos/qec/surface/circuit_gen.py @@ -33,7 +33,7 @@ def generate_stim_circuit( p1: float = 0.0, p2: float = 0.0, p_meas: float = 0.0, - p_init: float = 0.0, + p_prep: float = 0.0, ) -> str: """Generate a Stim circuit from SurfacePatch geometry. @@ -53,7 +53,7 @@ def generate_stim_circuit( p1: Single-qubit gate depolarizing error rate p2: Two-qubit gate depolarizing error rate p_meas: Measurement error rate (X_ERROR before M) - p_init: Initialization error rate (X_ERROR after R) + p_prep: Initialization error rate (X_ERROR after R) Returns: Stim circuit string with noise and detector annotations @@ -109,8 +109,8 @@ def z_anc_q(stab_idx: int) -> int: # Allocate data qubits (R = reset = qubit()) for i in range(num_data): lines.append(f"R {data_q(i)}") - if p_init > 0: - lines.append(f"X_ERROR({p_init}) {data_q(i)}") + if p_prep > 0: + lines.append(f"X_ERROR({p_prep}) {data_q(i)}") # For X-basis: H on each data qubit if basis.upper() == "X": @@ -140,14 +140,14 @@ def z_anc_q(stab_idx: int) -> int: # Guppy: ax{i} = qubit() for each X stabilizer for s in geom.x_stabilizers: lines.append(f"R {x_anc_q(s.index)}") - if p_init > 0: - lines.append(f"X_ERROR({p_init}) {x_anc_q(s.index)}") + if p_prep > 0: + lines.append(f"X_ERROR({p_prep}) {x_anc_q(s.index)}") # Guppy: az{i} = qubit() for each Z stabilizer for s in geom.z_stabilizers: lines.append(f"R {z_anc_q(s.index)}") - if p_init > 0: - lines.append(f"X_ERROR({p_init}) {z_anc_q(s.index)}") + if p_prep > 0: + lines.append(f"X_ERROR({p_prep}) {z_anc_q(s.index)}") lines.append("") @@ -341,7 +341,7 @@ def generate_circuit_level_dem( p1=p, p2=p, p_meas=p, - p_init=p, + p_prep=p, ) # Parse and generate DEM @@ -474,7 +474,7 @@ def compare_dems( stim_dem = generate_circuit_level_dem(patch, num_rounds, basis, p=p) # Generate phenomenological DEM - noise = NoiseModel(p1=p, p2=p, p_meas=p, p_init=p) + noise = NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p) stab_type = "X" if basis.upper() == "X" else "Z" phenom_dem = generate_surface_code_dem(patch, num_rounds, noise, stab_type) diff --git a/python/quantum-pecos/src/pecos/qec/surface/decode.py b/python/quantum-pecos/src/pecos/qec/surface/decode.py index 78844bf57..6ffb604b9 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/decode.py +++ b/python/quantum-pecos/src/pecos/qec/surface/decode.py @@ -57,6 +57,15 @@ from pecos.qec.surface.patch import Stabilizer, SurfacePatch +def _validate_probability(name: str, value: float) -> float: + """Return ``value`` as a float after validating it is a probability.""" + probability = float(value) + if not 0.0 <= probability <= 1.0: + msg = f"{name} must be a probability in [0, 1], got {value!r}" + raise ValueError(msg) + return probability + + class DecoderType(str, Enum): """Available decoder backends.""" @@ -70,32 +79,53 @@ class DecoderType(str, Enum): @dataclass class NoiseModel: - """Depolarizing noise parameters for surface code simulation. + """Circuit-level noise parameters for QEC simulation. - These parameters match the DepolarizingErrorModel in selene_sim. + Matches the Rust ``NoiseConfig`` type. All parameters are optional + beyond the four base rates. Attributes: - p1: Single-qubit gate error rate - p2: Two-qubit gate error rate - p_meas: Measurement error rate - p_init: Initialization error rate + p1: Single-qubit gate error rate. + p2: Two-qubit gate 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). """ - p1: float = 0.0 # Single-qubit gate error rate - p2: float = 0.0 # Two-qubit gate error rate - p_meas: float = 0.0 # Measurement error rate - p_init: float = 0.0 # Initialization error rate + p1: float = 0.0 + p2: float = 0.0 + p_meas: float = 0.0 + p_prep: float = 0.0 + p_idle: float | None = None + t1: float | None = None + t2: float | None = None + + @staticmethod + def uniform(physical_error_rate: float) -> NoiseModel: + """Create a uniform circuit-level noise model from one physical error rate.""" + p = _validate_probability("physical_error_rate", physical_error_rate) + return NoiseModel(p1=p, p2=p, p_meas=p, p_prep=p) @property def is_noiseless(self) -> bool: """True if all error rates are zero.""" - return self.p1 == 0.0 and self.p2 == 0.0 and self.p_meas == 0.0 and self.p_init == 0.0 + return ( + self.p1 == 0.0 + and self.p2 == 0.0 + and self.p_meas == 0.0 + and self.p_prep == 0.0 + and (self.p_idle is None or self.p_idle == 0.0) + ) @property def physical_error_rate(self) -> float: - """Approximate combined physical error rate (for DEM weights).""" - # Use maximum as a conservative estimate - return max(self.p1, self.p2, self.p_meas, self.p_init) + """Approximate combined physical error rate.""" + rates = [self.p1, self.p2, self.p_meas, self.p_prep] + if self.p_idle is not None: + rates.append(self.p_idle) + return max(rates) @dataclass @@ -518,6 +548,9 @@ def tuple_args(payload: Any, op_name: str, arity: int) -> tuple[Any, ...]: msg = f"Unsupported traced QIS quantum op {op_name!r}" raise ValueError(msg) + # Compact: ASAP-schedule gates into minimal ticks + tick_circuit.compact_ticks() + return tick_circuit @@ -538,12 +571,38 @@ def _gate_triples(qubits: list[int], gate_type: str) -> list[tuple[int, int, int def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> Any: - """Replay lowered post-Selene ByteMessage gate batches into a TickCircuit.""" + """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 + 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 Guppy result() objects: AllocateResult IDs from the + operations stream are stamped on MZ gates via mz_with_ids(). + """ from pecos_rslib.quantum import TickCircuit tick_circuit = TickCircuit() for chunk in chunks: + # Extract AllocateResult ID → MZ qubit mapping from the operations stream. + # Each AllocateResult(id=N) is followed by Quantum.Measure([qubit, slot]). + # This gives us the MeasId to stamp on each MZ gate. + meas_id_queue: list[tuple[int, int]] = [] # (qubit, meas_id) pairs + last_alloc_id: int | None = None + for op in chunk.get("operations") or []: + op_dict = dict(op) + if "AllocateResult" in op_dict: + last_alloc_id = int(op_dict["AllocateResult"]["id"]) + elif "Quantum" in op_dict: + q_op = op_dict["Quantum"] + if "Measure" in q_op and last_alloc_id is not None: + qubit = int(q_op["Measure"][0]) + meas_id_queue.append((qubit, last_alloc_id)) + last_alloc_id = None + + meas_id_idx = 0 # next MeasId to assign + for gate in chunk.get("lowered_quantum_ops") or []: gate_type = str(gate["gate_type"]) qubits = [int(q) for q in gate.get("qubits", [])] @@ -569,7 +628,26 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> elif gate_type == "PZ": tick.pz(qubits) elif gate_type == "MZ": - tick.mz(qubits) + # Stamp MeasIds from the AllocateResult stream + meas_ids = [] + for q in qubits: + if meas_id_idx < len(meas_id_queue): + expected_q, mid = meas_id_queue[meas_id_idx] + if expected_q == q: + meas_ids.append(mid) + meas_id_idx += 1 + else: + # Qubit mismatch — fall back to auto-assign + meas_ids = [] + break + else: + meas_ids = [] + break + + if meas_ids: + tick.mz_with_ids(qubits, meas_ids) + else: + tick.mz(qubits) elif gate_type == "RX": tick.rx(angles[0], qubits) elif gate_type == "RY": @@ -590,6 +668,8 @@ def _replay_lowered_qis_trace_into_tick_circuit(chunks: list[dict[str, Any]]) -> tick.crz(angles[0], _gate_pairs(qubits, gate_type)) elif gate_type == "SZZ": tick.szz(_gate_pairs(qubits, gate_type)) + elif gate_type == "SZZdg": + tick.szzdg(_gate_pairs(qubits, gate_type)) elif gate_type == "RZZ": tick.rzz(angles[0], _gate_pairs(qubits, gate_type)) elif gate_type == "CCX": @@ -598,6 +678,9 @@ 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) + # Compact: ASAP-schedule gates into minimal ticks + tick_circuit.compact_ticks() + return tick_circuit @@ -679,6 +762,63 @@ def _build_surface_tick_circuit_for_native_model( return traced_tc +def build_memory_circuit( + *, + rounds: int, + distance: int | None = None, + patch: SurfacePatch | None = None, + basis: str = "Z", + ancilla_budget: int | None = None, + circuit_source: Literal["abstract", "traced_qis"] = "abstract", +) -> Any: + """Build the standard surface-code memory ``TickCircuit``. + + This is the public, friendly entry point for the circuit used by PECOS's + native DEM, sampler, and decoder helpers. + + Args: + rounds: Number of syndrome-extraction rounds. + distance: Rotated surface-code distance. Provide either ``distance`` + or ``patch``. + patch: Explicit surface-code patch. Provide either ``patch`` or + ``distance``. + basis: Memory basis, ``"Z"`` or ``"X"``. + 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. + + Returns: + A Rust-backed ``TickCircuit`` with detector and observable metadata. + + Example: + >>> from pecos.qec.surface import build_memory_circuit + >>> tc = build_memory_circuit(distance=3, rounds=3, basis="Z") + >>> int(tc.get_meta("num_measurements")) > 0 + True + """ + from pecos.qec.surface.patch import SurfacePatch + + if rounds < 1: + msg = f"rounds must be >= 1, got {rounds}" + raise ValueError(msg) + if patch is None: + if distance is None: + msg = "build_memory_circuit requires either distance=... or patch=..." + raise ValueError(msg) + patch = SurfacePatch.create(distance=distance) + elif distance is not None: + msg = "build_memory_circuit accepts either distance=... or patch=..., not both" + raise ValueError(msg) + + return _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + ancilla_budget=ancilla_budget, + circuit_source=circuit_source, + ) + + def _can_use_cached_surface_topology( *, ancilla_budget: int | None, @@ -687,6 +827,21 @@ def _can_use_cached_surface_topology( return ancilla_budget is None +def _uses_dedicated_idle_noise( + *, + p_idle: float | None, + t1: float | None, + t2: float | 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) + + +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) + + @cache def _cached_surface_native_topology( patch_key: tuple[int, int, str, bool], @@ -694,6 +849,7 @@ def _cached_surface_native_topology( 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.""" import json @@ -709,6 +865,12 @@ def _cached_surface_native_topology( ancilla_budget=ancilla_budget, circuit_source=circuit_source, ) + if include_idle_gates: + # Insert idle gates only when the requested noise model includes a + # dedicated idle channel. Otherwise inserted idle gates receive ordinary + # one-qubit gate noise and change the explicit circuit-level DEM. + tc.fill_idle_gates() + dag = tc.to_dag_circuit() analyzer = DagFaultAnalyzer(dag) influence_map = analyzer.build_influence_map() @@ -740,7 +902,7 @@ def _dem_string_from_cached_surface_topology( dem = ( DemBuilder(topology.influence_map) - .with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_init) + .with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep, p_idle=noise.p_idle, t1=noise.t1, t2=noise.t2) .with_num_measurements(topology.num_measurements) .with_measurement_order(list(topology.measurement_order)) .with_detectors_json(topology.detectors_json) @@ -760,20 +922,25 @@ def _cached_surface_native_dem_string( p1: float, p2: float, p_meas: float, - p_init: float, + p_prep: float, decompose_errors: bool, + p_idle: float | None = None, + t1: float | None = None, + t2: 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) topology = _cached_surface_native_topology( patch_key, num_rounds, basis, ancilla_budget, circuit_source, + include_idle_gates, ) return _dem_string_from_cached_surface_topology( topology, - NoiseModel(p1=p1, p2=p2, p_meas=p_meas, p_init=p_init), + NoiseModel(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep, p_idle=p_idle, t1=t1, t2=t2), decompose_errors=decompose_errors, ) @@ -790,10 +957,14 @@ def _build_native_sampler_from_cached_surface_topology( topology: _CachedNativeSurfaceTopology, noise: NoiseModel, *, - sampling_model: Literal["dem", "influence_dem", "mnm"] = "dem", + sampling_model: Literal[ + "dem", + "influence_dem", + "mnm", + ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", ) -> NativeSampler: """Construct a native sampler from cached topology-only analysis.""" - from pecos.qec import DemSamplerBuilder, MemBuilder, ParsedDem + from pecos.qec import DemSampler, ParsedDem if sampling_model == "dem": dem_str = _dem_string_from_cached_surface_topology( @@ -802,20 +973,25 @@ def _build_native_sampler_from_cached_surface_topology( decompose_errors=True, ) sampler = ParsedDem.from_string(dem_str).to_dem_sampler() - elif sampling_model == "influence_dem": - sampler = ( - DemSamplerBuilder(topology.influence_map) - .with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_init) - .with_detectors_json(topology.detectors_json) - .with_observables_json(topology.observables_json) - .with_measurement_order(list(topology.measurement_order)) - .build() + 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, ) - elif sampling_model == "mnm": - builder = MemBuilder(topology.influence_map) - builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_init) - builder.with_measurement_order(list(topology.measurement_order)) - sampler = builder.build() + # Remap sampling_model for NativeSampler dispatch + sampling_model = "influence_dem" else: msg = f"Unknown native sampling_model {sampling_model!r}" raise ValueError(msg) @@ -834,13 +1010,20 @@ def _build_native_sampler_from_tick_circuit( tc: Any, noise: NoiseModel, *, - sampling_model: Literal["dem", "influence_dem", "mnm"] = "dem", + sampling_model: Literal[ + "dem", + "influence_dem", + "mnm", + ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", ) -> NativeSampler: """Construct a native sampler directly from a TickCircuit.""" import json - from pecos.qec import DagFaultAnalyzer, DemSamplerBuilder, MemBuilder, ParsedDem - from pecos.qec.surface.circuit_builder import _extract_measurement_order, generate_dem_from_tick_circuit + from pecos.qec import DagFaultAnalyzer, DemSampler, ParsedDem + from pecos.qec.surface.circuit_builder import generate_dem_from_tick_circuit + + if _noise_uses_dedicated_idle_noise(noise): + tc.fill_idle_gates() dag = tc.to_dag_circuit() analyzer = DagFaultAnalyzer(dag) @@ -848,8 +1031,6 @@ def _build_native_sampler_from_tick_circuit( detectors_json = tc.get_meta("detectors") or "[]" observables_json = tc.get_meta("observables") or "[]" - measurement_order = _extract_measurement_order(tc) - num_detectors = len(json.loads(detectors_json)) if detectors_json else 0 num_observables = len(json.loads(observables_json)) if observables_json else 0 @@ -859,24 +1040,40 @@ def _build_native_sampler_from_tick_circuit( p1=noise.p1, p2=noise.p2, p_meas=noise.p_meas, - p_init=noise.p_init, + p_prep=noise.p_prep, + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, decompose_errors=True, ) sampler = ParsedDem.from_string(dem_str).to_dem_sampler() - elif sampling_model == "influence_dem": - sampler = ( - DemSamplerBuilder(influence_map) - .with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_init) - .with_detectors_json(detectors_json) - .with_observables_json(observables_json) - .with_measurement_order(measurement_order) - .build() + elif sampling_model in ("influence_dem", "mnm"): + det_records = [d["records"] for d in json.loads(detectors_json)] + obs_records = [o["records"] for o in json.loads(observables_json)] if observables_json else [] + sampler = DemSampler.with_detectors( + 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, + ) + sampling_model = "influence_dem" + elif sampling_model == "from_circuit": + # Direct from_circuit path: uses DagCircuit annotations and any + # explicit idle locations inserted above for dedicated idle noise. + sampler = DemSampler.from_circuit( + dag, + p1=noise.p1, + p2=noise.p2, + p_meas=noise.p_meas, + p_prep=noise.p_prep, + p_idle=noise.p_idle, ) - elif sampling_model == "mnm": - builder = MemBuilder(influence_map) - builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_init) - builder.with_measurement_order(measurement_order) - sampler = builder.build() else: msg = f"Unknown native sampling_model {sampling_model!r}" raise ValueError(msg) @@ -952,8 +1149,11 @@ def generate_circuit_level_dem_from_builder( noise.p1, noise.p2, noise.p_meas, - noise.p_init, + noise.p_prep, decompose_errors=decompose_errors, + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, ) tc = _build_surface_tick_circuit_for_native_model( @@ -963,12 +1163,17 @@ def generate_circuit_level_dem_from_builder( ancilla_budget=ancilla_budget, circuit_source=circuit_source, ) + if _noise_uses_dedicated_idle_noise(noise): + tc.fill_idle_gates() return generate_dem_from_tick_circuit( tc, p1=noise.p1, p2=noise.p2, p_meas=noise.p_meas, - p_init=noise.p_init, + p_prep=noise.p_prep, + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, decompose_errors=decompose_errors, ) @@ -1020,7 +1225,7 @@ def generate_circuit_level_dem( rounds=num_rounds, after_clifford_depolarization=noise.p2 if noise.p2 > 0 else 0.0, before_measure_flip_probability=noise.p_meas if noise.p_meas > 0 else 0.0, - after_reset_flip_probability=noise.p_init if noise.p_init > 0 else 0.0, + after_reset_flip_probability=noise.p_prep if noise.p_prep > 0 else 0.0, ) # Generate DEM from circuit @@ -2157,6 +2362,67 @@ def decode_memory_x( return is_logical_error, result + def _get_css_uf_decoder(self) -> Any: + """Get or create the UIUF CSS UF decoder.""" + if not hasattr(self, "_css_uf_decoder") or self._css_uf_decoder is None: + from pecos_rslib.qec import CssUfDecoder + + x_dem = self.get_dem("X", circuit_level=True) + z_dem = self.get_dem("Z", circuit_level=True) + # Strip logical_observable lines (not needed for matching graph). + x_dem = "\n".join(line for line in x_dem.split("\n") if not line.startswith("logical_observable")) + z_dem = "\n".join(line for line in z_dem.split("\n") if not line.startswith("logical_observable")) + self._css_uf_decoder = CssUfDecoder(x_dem, z_dem) + return self._css_uf_decoder + + def decode_memory_z_uiuf( + self, + synx_list: list, + synz_list: list, + final: NDArray[np.uint8] | list[int], + ) -> tuple[bool, DecodingResult]: + """Decode Z-basis memory using UIUF (joint X/Z intersection). + + Like ``decode_memory_z`` but uses both X and Z syndromes jointly + to identify Y errors and improve accuracy. + + Args: + 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 + + Returns: + (is_logical_error, decoding_result) + """ + import numpy as np + + geom = self.patch.geometry + logical_z_qubits = geom.logical_z.data_qubits if geom.logical_z else () + final_parity = sum(final[q] for q in logical_z_qubits) % 2 + + # Compute detection events for both bases. + x_events = self._compute_dem_detection_events_x(synx_list, synz_list, final) + z_events = self._compute_dem_detection_events_z(synx_list, synz_list, final) + x_flat = x_events.ravel().astype(np.uint8) + z_flat = z_events.ravel().astype(np.uint8) + + # Joint decode via UIUF. + decoder = self._get_css_uf_decoder() + x_obs, z_obs = decoder.decode_css(bytes(x_flat), bytes(z_flat)) + + # For Z-basis memory, we care about the Z observable (L0). + predicted_obs = z_obs & 1 + corrected_parity = (final_parity + predicted_obs) % 2 + is_logical_error = corrected_parity != 0 + + return is_logical_error, DecodingResult( + x_correction=np.zeros(self.patch.num_data, dtype=np.uint8), + z_correction=np.zeros(self.patch.num_data, dtype=np.uint8), + logical_x_flip=bool(x_obs & 1), + logical_z_flip=bool(predicted_obs), + decoding_weight=0.0, + ) + @dataclass class SimulationResult: @@ -2187,6 +2453,107 @@ class SimulationResult: decoder_type: str | None = None +def _memory_noise_model( + physical_error_rate: float | None, + noise_model: NoiseModel | None, +) -> NoiseModel: + """Resolve the surface-memory noise inputs into an explicit NoiseModel.""" + if noise_model is not None: + if physical_error_rate is not None: + msg = "pass either physical_error_rate or noise_model, not both" + raise ValueError(msg) + return noise_model + p = 0.001 if physical_error_rate is None else physical_error_rate + return NoiseModel.uniform(p) + + +def surface_code_memory( + *, + distance: int = 3, + physical_error_rate: float | None = None, + noise_model: NoiseModel | None = None, + shots: int = 1000, + rounds: int | None = None, + basis: str = "Z", + decoder_type: str = "pymatching", + seed: int | None = None, + decode: bool = True, + circuit_source: Literal["abstract", "traced_qis"] = "abstract", + ancilla_budget: int | None = None, +) -> SimulationResult: + """Run the recommended native surface-code memory workflow. + + This helper keeps the quick-start path short while using PECOS's Rust-backed + circuit-level DEM sampler and decoder machinery internally. + + Args: + distance: Rotated surface-code distance. + physical_error_rate: Uniform physical error rate used for one-qubit + gates, two-qubit gates, measurements, and preparation. Defaults to + ``0.001`` when ``noise_model`` is not provided. + noise_model: Explicit circuit-level noise model. Mutually exclusive + with ``physical_error_rate``. + shots: Number of Monte Carlo shots. + rounds: Number of syndrome-extraction rounds. Defaults to ``distance``. + basis: Memory basis, ``"Z"`` or ``"X"``. + decoder_type: Decoder backend passed to ``SampleBatch.decode_count``. + seed: Optional sampler seed. + 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. + + Returns: + ``SimulationResult`` with logical and raw error counts/rates. + + Example: + >>> from pecos.qec.surface import surface_code_memory + >>> result = surface_code_memory(distance=3, physical_error_rate=0.0, shots=4, rounds=1) + >>> result.logical_error_rate + 0.0 + """ + from pecos.qec import ParsedDem + from pecos.qec.surface.patch import SurfacePatch + + if distance < 1: + msg = f"distance must be >= 1, got {distance}" + raise ValueError(msg) + if shots < 0: + 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}" + raise ValueError(msg) + + noise_model = _memory_noise_model(physical_error_rate, noise_model) + patch = SurfacePatch.create(distance=distance) + dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=num_rounds, + noise=noise_model, + basis=basis, + decompose_errors=True, + ancilla_budget=ancilla_budget, + circuit_source=circuit_source, + ) + 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) + num_logical_errors = batch.decode_count(dem, decoder_type) if decode else num_raw_errors + + return SimulationResult( + distance=distance, + num_shots=shots, + num_rounds=num_rounds, + basis=basis, + num_logical_errors=num_logical_errors, + num_raw_errors=num_raw_errors, + logical_error_rate=num_logical_errors / shots if shots else 0.0, + raw_error_rate=num_raw_errors / shots if shots else 0.0, + decoded=decode, + decoder_type=decoder_type if decode else None, + ) + + def run_noisy_memory_experiment( distance: int, num_rounds: int, @@ -2219,7 +2586,7 @@ def run_noisy_memory_experiment( Example: >>> from pecos.qec.surface import run_noisy_memory_experiment, NoiseModel - >>> noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_init=0.001) + >>> noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) >>> result = run_noisy_memory_experiment( ... distance=3, ... num_rounds=3, @@ -2249,11 +2616,13 @@ def run_noisy_memory_experiment( # Create decoder if needed decoder = None if decode: + # UIUF uses pymatching-type DEMs internally (decoded via CssUfDecoder). + dt = "pymatching" if decoder_type == "pecos_uf_uiuf" else decoder_type decoder = SurfaceDecoder( patch, num_rounds=num_rounds, noise=noise, - decoder_type=decoder_type, + decoder_type=dt, ) # Build and compile circuit @@ -2267,7 +2636,7 @@ def run_noisy_memory_experiment( p_1q=noise.p1, p_2q=noise.p2, p_meas=noise.p_meas, - p_init=noise.p_init, + p_init=noise.p_prep, ) # Run shots @@ -2308,7 +2677,9 @@ def run_noisy_memory_experiment( final_arr = np.array(final, dtype=np.uint8) # Decode based on basis - if basis.upper() == "Z": + if decoder_type == "pecos_uf_uiuf" and basis.upper() == "Z": + is_error, _ = decoder.decode_memory_z_uiuf(synx_list, synz_list, final_arr) + elif basis.upper() == "Z": is_error, _ = decoder.decode_memory_z(synx_list, synz_list, final_arr) else: is_error, _ = decoder.decode_memory_x(synx_list, synz_list, final_arr) @@ -2350,8 +2721,7 @@ class NativeSampler: Two sampling backends are available: - `dem` (default): sample the generated decomposed DEM via `ParsedDem` - - `influence_dem`: sample directly from the influence-map detector mechanisms - - `mnm`: samples measurement outcomes first via `MeasurementNoiseModel` + - `influence_dem`: sample directly from the influence-map via `DemSampler` Attributes: sampler: The underlying Rust sampler object @@ -2367,7 +2737,9 @@ class NativeSampler: observables_json: str num_detectors: int num_observables: int - sampling_model: Literal["dem", "influence_dem", "mnm"] = "dem" + sampling_model: Literal["dem", "influence_dem", "mnm"] = ( + "dem" # "mnm" accepted for compat, mapped to "influence_dem" + ) def sample( self, @@ -2387,18 +2759,7 @@ def sample( - detection_events: shape (num_shots, num_detectors) - observable_flips: shape (num_shots, num_observables) """ - if self.sampling_model in ("dem", "influence_dem"): - det_events, obs_flips = self.sampler.sample_batch(num_shots, seed) - elif self.sampling_model == "mnm": - det_events, obs_flips = self.sampler.sample_batch_for_decoding( - num_shots, - self.detectors_json, - self.observables_json, - seed, - ) - else: - msg = f"Unknown native sampling_model {self.sampling_model!r}" - raise ValueError(msg) + det_events, obs_flips = self.sampler.sample_batch(num_shots, seed) return np.array(det_events, dtype=bool), np.array(obs_flips, dtype=bool) @@ -2409,7 +2770,11 @@ def build_native_sampler( basis: str = "Z", ancilla_budget: int | None = None, circuit_source: Literal["abstract", "traced_qis"] = "abstract", - sampling_model: Literal["dem", "influence_dem", "mnm"] = "dem", + sampling_model: Literal[ + "dem", + "influence_dem", + "mnm", + ] = "dem", # "mnm" accepted for compat, mapped to "influence_dem", ) -> NativeSampler: """Build a PECOS native sampler for threshold estimation. @@ -2420,10 +2785,8 @@ def build_native_sampler( The pipeline is: - `sampling_model="dem"`: TickCircuit -> DemBuilder -> ParsedDem -> DemSampler - - `sampling_model="influence_dem"`: - TickCircuit -> DagCircuit -> DagFaultAnalyzer -> InfluenceMap -> direct mechanism sampler - - `sampling_model="mnm"`: - TickCircuit -> DagCircuit -> DagFaultAnalyzer -> InfluenceMap -> MeasurementNoiseModel -> Sampler + - `sampling_model="influence_dem"` (or `"mnm"` for compat): + TickCircuit -> DagCircuit -> DagFaultAnalyzer -> InfluenceMap -> DemSampler (with detector defs) Args: patch: Surface code patch with geometry @@ -2438,9 +2801,9 @@ def build_native_sampler( before native PECOS fault analysis. sampling_model: Which native sampling backend to use. ``"dem"`` samples the generated decomposed DEM and is the default. - ``"influence_dem"`` uses the older direct influence-map sampler for - debugging. ``"mnm"`` preserves the measurement-noise-model - approximation. + ``"influence_dem"`` uses the influence-map-based DemSampler with + detector definitions. ``"mnm"`` is accepted for compatibility + and maps to ``"influence_dem"``. Returns: NativeSampler that can generate samples for threshold estimation @@ -2461,6 +2824,7 @@ def build_native_sampler( basis, ancilla_budget, circuit_source, + _noise_uses_dedicated_idle_noise(noise), ) if sampling_model == "dem": dem_str = _cached_surface_native_dem_string( @@ -2472,8 +2836,11 @@ def build_native_sampler( noise.p1, noise.p2, noise.p_meas, - noise.p_init, + noise.p_prep, decompose_errors=True, + p_idle=noise.p_idle, + t1=noise.t1, + t2=noise.t2, ) sampler = _cached_parsed_dem(dem_str).to_dem_sampler() return NativeSampler( diff --git a/python/quantum-pecos/src/pecos/qec/surface/layouts/rotated_lattice.py b/python/quantum-pecos/src/pecos/qec/surface/layouts/rotated_lattice.py index 1d92b947c..e62938f88 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/layouts/rotated_lattice.py +++ b/python/quantum-pecos/src/pecos/qec/surface/layouts/rotated_lattice.py @@ -32,22 +32,28 @@ class RotatedPosition: y: int -def compute_rotated_x_stabilizers(d: int) -> list[StabilizerSupport]: +def compute_rotated_x_stabilizers(dx: int, dz: int | None = None) -> list[StabilizerSupport]: """Compute X stabilizer supports for rotated surface code. X stabilizers are placed at dual lattice faces where (row + col) is odd. Boundary X stabilizers (weight 2) are on the top and bottom edges. - This convention matches the QASM reference and Rust implementations. + Degenerate cases: + - dx=1: no X stabilizers (single row, no top/bottom boundary) + - dz=1: no X stabilizers (single column, no X-type faces) + - dx=1, dz=1: single qubit, no stabilizers Args: - d: Code distance (must be odd >= 3) + dx: Number of rows (X distance). Must be >= 1. + dz: Number of columns (Z distance). Must be >= 1. Defaults to dx. Returns: List of StabilizerSupport for X stabilizers """ - if d < 3 or d % 2 == 0: - msg = f"Distance must be odd >= 3, got {d}" + if dz is None: + dz = dx + if dx < 1 or dz < 1: + msg = f"Distances must be >= 1, got dx={dx}, dz={dz}" raise ValueError(msg) supports = [] @@ -55,7 +61,7 @@ def compute_rotated_x_stabilizers(d: int) -> list[StabilizerSupport]: # Top boundary X stabilizers (weight 2) # Virtual dual row r=-1: X-type when (-1+col)%2==1, i.e. col even - for col in range(0, d - 1, 2): + for col in range(0, dz - 1, 2): q1 = col q2 = col + 1 supports.append( @@ -68,13 +74,13 @@ def compute_rotated_x_stabilizers(d: int) -> list[StabilizerSupport]: stab_idx += 1 # Bulk X stabilizers (weight 4) - for row in range(d - 1): - for col in range(d - 1): + for row in range(dx - 1): + for col in range(dz - 1): if (row + col) % 2 == 1: - q_tl = row * d + col - q_tr = row * d + col + 1 - q_bl = (row + 1) * d + col - q_br = (row + 1) * d + col + 1 + q_tl = row * dz + col + q_tr = row * dz + col + 1 + q_bl = (row + 1) * dz + col + q_br = (row + 1) * dz + col + 1 supports.append( StabilizerSupport( @@ -86,10 +92,11 @@ def compute_rotated_x_stabilizers(d: int) -> list[StabilizerSupport]: stab_idx += 1 # Bottom boundary X stabilizers (weight 2) - # Virtual dual row r=d-1: for odd d, (d-1+col)%2==1 requires col odd - for col in range(1, d - 1, 2): - q1 = (d - 1) * d + col - q2 = (d - 1) * d + col + 1 + # Virtual dual row r=dx-1: X-type when (dx-1+col)%2==1 + bottom_start = 1 if dx % 2 == 1 else 0 + for col in range(bottom_start, dz - 1, 2): + q1 = (dx - 1) * dz + col + q2 = (dx - 1) * dz + col + 1 supports.append( StabilizerSupport( index=stab_idx, @@ -102,32 +109,39 @@ def compute_rotated_x_stabilizers(d: int) -> list[StabilizerSupport]: return supports -def compute_rotated_z_stabilizers(d: int) -> list[StabilizerSupport]: +def compute_rotated_z_stabilizers(dx: int, dz: int | None = None) -> list[StabilizerSupport]: """Compute Z stabilizer supports for rotated surface code. Z stabilizers are placed at dual lattice faces where (row + col) is even. Boundary Z stabilizers (weight 2) are on the left and right edges. - This convention matches the QASM reference and Rust implementations. + Degenerate cases: + - dz=1: no Z stabilizers (single column, no left/right boundary) + - dx=1: Z stabilizers become the repetition code parity checks + - dx=1, dz=1: single qubit, no stabilizers Args: - d: Code distance (must be odd >= 3) + dx: Number of rows (X distance). Must be >= 1. + dz: Number of columns (Z distance). Must be >= 1. Defaults to dx. Returns: List of StabilizerSupport for Z stabilizers """ - if d < 3 or d % 2 == 0: - msg = f"Distance must be odd >= 3, got {d}" + if dz is None: + dz = dx + if dx < 1 or dz < 1: + msg = f"Distances must be >= 1, got dx={dx}, dz={dz}" raise ValueError(msg) supports = [] stab_idx = 0 # Right boundary Z stabilizers (weight 2) - # Virtual dual col c=d-1: Z-type when (row+d-1)%2==0, i.e. row even (for odd d) - for row in range(0, d - 1, 2): - q1 = row * d + (d - 1) - q2 = (row + 1) * d + (d - 1) + # Virtual dual col c=dz-1: Z-type when (row+dz-1)%2==0 + right_start = 0 if dz % 2 == 1 else 1 + for row in range(right_start, dx - 1, 2): + q1 = row * dz + (dz - 1) + q2 = (row + 1) * dz + (dz - 1) supports.append( StabilizerSupport( index=stab_idx, @@ -138,13 +152,13 @@ def compute_rotated_z_stabilizers(d: int) -> list[StabilizerSupport]: stab_idx += 1 # Bulk Z stabilizers (weight 4) - for row in range(d - 1): - for col in range(d - 1): + for row in range(dx - 1): + for col in range(dz - 1): if (row + col) % 2 == 0: - q_tl = row * d + col - q_tr = row * d + col + 1 - q_bl = (row + 1) * d + col - q_br = (row + 1) * d + col + 1 + q_tl = row * dz + col + q_tr = row * dz + col + 1 + q_bl = (row + 1) * dz + col + q_br = (row + 1) * dz + col + 1 supports.append( StabilizerSupport( @@ -156,10 +170,10 @@ def compute_rotated_z_stabilizers(d: int) -> list[StabilizerSupport]: stab_idx += 1 # Left boundary Z stabilizers (weight 2) - # Virtual dual col c=-1: Z-type when (row+(-1))%2==0, i.e. row odd - for row in range(1, d - 1, 2): - q1 = row * d - q2 = (row + 1) * d + # Virtual dual col c=-1: Z-type when (row-1)%2==0, i.e. row odd + for row in range(1, dx - 1, 2): + q1 = row * dz + q2 = (row + 1) * dz supports.append( StabilizerSupport( index=stab_idx, @@ -172,8 +186,8 @@ def compute_rotated_z_stabilizers(d: int) -> list[StabilizerSupport]: # Re-order: left-to-right (ascending column), bottom-to-top (descending row) supports.sort( key=lambda s: ( - sum(q % d for q in s.data_qubits) / len(s.data_qubits), - -sum(q // d for q in s.data_qubits) / len(s.data_qubits), + sum(q % dz for q in s.data_qubits) / len(s.data_qubits), + -sum(q // dz for q in s.data_qubits) / len(s.data_qubits), ), ) return [ @@ -181,14 +195,18 @@ def compute_rotated_z_stabilizers(d: int) -> list[StabilizerSupport]: ] -def get_rotated_logical_x(d: int) -> tuple[int, ...]: - """Get logical X operator qubits (left edge).""" - return tuple(i * d for i in range(d)) +def get_rotated_logical_x(dx: int, dz: int | None = None) -> tuple[int, ...]: + """Get logical X operator qubits (left edge, weight dx).""" + if dz is None: + dz = dx + return tuple(i * dz for i in range(dx)) -def get_rotated_logical_z(d: int) -> tuple[int, ...]: - """Get logical Z operator qubits (top edge).""" - return tuple(range(d)) +def get_rotated_logical_z(dx: int, dz: int | None = None) -> tuple[int, ...]: + """Get logical Z operator qubits (top edge, weight dz).""" + if dz is None: + dz = dx + return tuple(range(dz)) def rotated_id_to_position(qubit_id: int, d: int) -> tuple[int, int]: diff --git a/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py new file mode 100644 index 000000000..c5d10a619 --- /dev/null +++ b/python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py @@ -0,0 +1,1587 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Logical circuit builder for surface codes with transversal gates. + +Generates PECOS TickCircuit circuits natively, with Stim circuit strings +derived via ``tick_circuit_to_stim()``. Supports: + +- Memory experiments (syndrome extraction rounds) +- Transversal Hadamard (H on all data qubits, swaps X<->Z stabilizers) +- Transversal CNOT (CX between corresponding data qubits of two patches) +- Transversal SZ via gate teleportation (CX + |+Y> ancilla consumption) + +Output formats: + +- ``to_tick_circuit()`` -- PECOS TickCircuit (source of truth) +- ``to_dag_circuit()`` -- PECOS DagCircuit (for fault analysis) +- ``to_stim()`` -- Stim circuit string (derived from TickCircuit) +- ``build_dem()`` -- DEM via PECOS DagFaultAnalyzer (no Stim) +- ``build_decoder()`` -- integrated decoder pipeline + +References: +- Geher et al., "Error-corrected Hadamard gate" (arXiv:2312.11605) +- Sahay et al., "Error correction of transversal CNOT" (arXiv:2408.01393) +- Serra-Peralta et al., "Decoding across transversal Clifford gates" (arXiv:2505.13599) +""" + +from __future__ import annotations + +import json +from dataclasses import dataclass, field +from enum import Enum, auto +from typing import TYPE_CHECKING + +if TYPE_CHECKING: + from pecos.qec.surface.patch import Stabilizer, SurfacePatch + +PatchSnapshot = dict[str, tuple[bool, list[str], list[str], list[str], list[str]]] + + +class LogicalGateType(Enum): + """Types of logical operations in a surface code circuit.""" + + MEMORY = auto() + TRANSVERSAL_H = auto() + TRANSVERSAL_SZ = auto() + TRANSVERSAL_SZdg = auto() + TRANSVERSAL_CX = auto() + + +@dataclass +class PatchState: + """Tracks the stabilizer assignment state of a patch. + + After transversal H, X-stabilizers become Z-stabilizers and vice versa. + This state tracks which physical stabilizers are currently X-type vs Z-type, + so that detectors can be formed correctly across gate boundaries. + """ + + patch: SurfacePatch + label: str + qubit_offset: int = 0 + coord_offset: tuple[float, float] = (0.0, 0.0) + x_z_swapped: bool = False + # Teleportation corrections: ancilla Z measurements to XOR into this + # patch's observable (from CX teleportation gates). + z_obs_includes: list[str] = field(default_factory=list) + x_obs_includes: list[str] = field(default_factory=list) + # After CX, some observables become non-reliable depending on the + # measurement basis. Track which bases are "entangled" and with whom. + # x_entangled_with: this patch's X observable is entangled with these + # patches (measuring X on this patch alone is non-deterministic). + x_entangled_with: list[str] = field(default_factory=list) + z_entangled_with: list[str] = field(default_factory=list) + + @property + def current_x_stabilizers(self) -> list[Stabilizer]: + """Stabilizers currently measuring X-type checks.""" + if self.x_z_swapped: + return self.patch.geometry.z_stabilizers + return self.patch.geometry.x_stabilizers + + @property + def current_z_stabilizers(self) -> list[Stabilizer]: + """Stabilizers currently measuring Z-type checks.""" + if self.x_z_swapped: + return self.patch.geometry.x_stabilizers + return self.patch.geometry.z_stabilizers + + +@dataclass +class LogicalOp: + """A logical operation in the circuit.""" + + gate_type: LogicalGateType + patches: list[str] + rounds: int = 0 + basis: str = "Z" + per_patch_basis: dict[str, str] = field(default_factory=dict) + # For teleportation CX: the target's Z measurement should be included + # in the control's observable for the Pauli frame correction. + teleportation: bool = False + # Type of magic state injection: "T" for T-gate, "SZ" for SZ, or None. + # Used by build_algorithm_descriptor() to emit the correct boundary gate. + injection_type: str | None = None + + +class LogicalCircuitBuilder: + """Builds surface code circuits with transversal gates. + + Composes logical operations on one or more patches, generating Stim + circuits with correct detector annotations across gate boundaries. + + Example:: + + patch = SurfacePatch.create(distance=3) + builder = LogicalCircuitBuilder() + builder.add_patch(patch, "A") + builder.add_memory("A", rounds=3, basis="Z") + builder.add_transversal_h("A") + builder.add_memory("A", rounds=3, basis="X") + stim_str = builder.to_stim(p1=0.001, p2=0.001) + """ + + def __init__(self) -> None: + """Initialize an empty logical circuit builder.""" + self._patches: dict[str, PatchState] = {} + self._operations: list[LogicalOp] = [] + + def add_patch( + self, + patch: SurfacePatch, + label: str, + qubit_offset: int = 0, + coord_offset: tuple[float, float] | None = None, + ) -> None: + """Register a surface code patch. + + Args: + patch: The surface code patch. + label: Unique label for this patch. + qubit_offset: Offset added to all qubit indices for this patch. + Use this when multiple patches share a qubit index space. + coord_offset: (dx, dy) spatial offset for this patch's + QUBIT_COORDS and DETECTOR coordinates. If None, computed + automatically based on patch index (patches are spaced + apart so coordinates don't overlap). + """ + if label in self._patches: + msg = f"Patch '{label}' already registered" + raise ValueError(msg) + if coord_offset is None: + # Auto-space: shift each patch by (d*2 + 2) * patch_index in x + patch_idx = len(self._patches) + spacing = patch.geometry.dz * 2 + 2 + coord_offset = (patch_idx * spacing, 0.0) + self._patches[label] = PatchState( + patch=patch, + label=label, + qubit_offset=qubit_offset, + coord_offset=coord_offset, + ) + + def add_memory( + self, + patch_labels: str | list[str], + rounds: int, + basis: str | dict[str, str] = "Z", + ) -> None: + """Add syndrome extraction rounds for one or more patches. + + When multiple patches are given, their syndrome extraction runs + in parallel (same time window). + + Args: + patch_labels: Label(s) of the patch(es). String for single + patch, list for parallel multi-patch. + rounds: Number of syndrome extraction rounds. + basis: Measurement basis. Either a single string ('X', 'Y', 'Z') + applied to all patches, or a dict mapping patch labels to + their individual basis (e.g., ``{"D": "Z", "Y": "Y"}``). + Only used for initialization and final measurement. + """ + if isinstance(patch_labels, str): + patch_labels = [patch_labels] + for label in patch_labels: + if label not in self._patches: + msg = f"Unknown patch '{label}'" + raise ValueError(msg) + + if isinstance(basis, str): + default_basis = basis.upper() + per_patch = {} + else: + default_basis = "Z" + per_patch = {k: v.upper() for k, v in basis.items()} + + self._operations.append( + LogicalOp( + gate_type=LogicalGateType.MEMORY, + patches=list(patch_labels), + rounds=rounds, + basis=default_basis, + per_patch_basis=per_patch, + ), + ) + + def _require_square(self, patch_label: str, gate_name: str) -> None: + """Check that a patch is square (dx=dz), required for transversal gates.""" + patch = self._patches[patch_label].patch + if patch.geometry.dx != patch.geometry.dz: + msg = f"{gate_name} requires a square patch (dx=dz), got dx={patch.geometry.dx}, dz={patch.geometry.dz}" + raise ValueError(msg) + + def add_transversal_h(self, patch_label: str) -> None: + """Add a transversal Hadamard gate on a patch. + + Applies H to every data qubit. After this: + - X-stabilizers become Z-stabilizers and vice versa + - Logical X and logical Z are exchanged + - Detectors at the boundary compare cross-type measurements + + The patch must be square (dx=dz) for the code to remain valid. + + Args: + patch_label: Label of the patch. + """ + if patch_label not in self._patches: + msg = f"Unknown patch '{patch_label}'" + raise ValueError(msg) + self._require_square(patch_label, "Transversal H") + self._operations.append( + LogicalOp( + gate_type=LogicalGateType.TRANSVERSAL_H, + patches=[patch_label], + ), + ) + + def add_transversal_sz(self, patch_label: str) -> None: + """Add a fold-transversal SZ gate on a patch. + + Implements the fold-transversal S gate using the Bravyi et al. + half-cycle trick (arXiv:2412.01391). The fold operation is + inserted at mid-cycle of a syndrome extraction round: + - On-diagonal data qubits (r + c = d-1): SZ gate + - On-diagonal X-ancilla qubits: SZdg gate + - Off-diagonal: CZ between each qubit and its mirror + + After this gate: + - Z-stabilizers are unchanged + - X-stabilizers pick up Z-stabilizer partners: + X_stab -> X_stab * Z_stab_mirror + + The patch must be square (dx=dz). + + Args: + patch_label: Label of the patch. + """ + if patch_label not in self._patches: + msg = f"Unknown patch '{patch_label}'" + raise ValueError(msg) + self._require_square(patch_label, "Transversal SZ") + self._operations.append( + LogicalOp( + gate_type=LogicalGateType.TRANSVERSAL_SZ, + patches=[patch_label], + ), + ) + + def add_transversal_szdg(self, patch_label: str) -> None: + """Add SZdg (S-dagger) on all data qubits of a patch. + + Inverse of add_transversal_sz. Used for mirroring circuits + that contain SZ gates. + """ + if patch_label not in self._patches: + msg = f"Unknown patch '{patch_label}'" + raise ValueError(msg) + self._require_square(patch_label, "Transversal SZdg") + self._operations.append( + LogicalOp( + gate_type=LogicalGateType.TRANSVERSAL_SZdg, + patches=[patch_label], + ), + ) + + def add_sz_via_teleportation( + self, + data_label: str, + ancilla_label: str, + rounds_before: int = 3, + rounds_after: int = 3, + ) -> None: + """Apply logical SZ via gate teleportation with |+Y> ancilla. + + Complete protocol: + 1. Prepare ancilla in |+Y> = S|+> (non-fault-tolerant injection) + 2. Syndrome rounds to project ancilla into code space + 3. Transversal CX(data=control, ancilla=target) + 4. Syndrome rounds + 5. Ancilla measured in Z-basis (final round) + + After CX, data has S|psi> (up to Z correction from ancilla outcome). + The Z correction is a Pauli frame update tracked by the decoder. + + Note: The |+Y> injection is non-fault-tolerant (distance-1). + For fault-tolerant SZ, use magic state distillation on the + injected state before consumption. + + Args: + data_label: Label of the data patch (receives the SZ gate). + ancilla_label: Label of the ancilla patch (consumed). + rounds_before: Syndrome rounds before CX. + rounds_after: Syndrome rounds after CX. + """ + # Step 1: Init both patches — data continues in Z, ancilla in |+Y>. + # Per-patch basis lets us do this in a single parallel segment. + self.add_memory( + [data_label, ancilla_label], + rounds=rounds_before, + basis={data_label: "Z", ancilla_label: "Y"}, + ) + # Step 2: CX(data=control, ancilla=target) — teleports S onto data. + # Marked as teleportation so observable propagation includes the + # ancilla's Z measurement as a Pauli frame correction. + self._operations.append( + LogicalOp( + gate_type=LogicalGateType.TRANSVERSAL_CX, + patches=[data_label, ancilla_label], + teleportation=True, + ), + ) + # Step 3: Post-CX extraction. Ancilla measured in Z-basis at final round. + # If ancilla measures logical -1, apply Z correction (Pauli frame update). + self.add_memory([data_label, ancilla_label], rounds=rounds_after, basis="Z") + + def add_t_via_injection( + self, + data_label: str, + ancilla_label: str, + rounds_before: int = 3, + rounds_after: int = 3, + ) -> None: + """Apply logical T gate via magic state injection. + + Complete protocol: + 1. Prepare ancilla in |T> = T|+> (non-fault-tolerant injection) + 2. Syndrome rounds to project ancilla into code space + 3. Transversal CX(data=control, ancilla=target) + 4. Syndrome rounds + 5. Ancilla measured in Z-basis (final round) + + After CX, data has T|psi> (up to S correction from ancilla outcome). + The S correction is a conditional feed-forward operation — this is + a DECISION POINT where the decoder must provide the Pauli frame. + + The corrected measurement outcome determines: + corrected = raw_measurement XOR frame[z_obs_bit] + if corrected == 1: apply S gate on data + + Note: The |T> injection is non-fault-tolerant (distance-1). + For fault-tolerant T, use magic state distillation on the + injected state before consumption. + + Args: + data_label: Label of the data patch (receives the T gate). + ancilla_label: Label of the ancilla patch (consumed). + rounds_before: Syndrome rounds before CX. + rounds_after: Syndrome rounds after CX. + """ + # Step 1: Init both patches — data continues in Z, ancilla gets |+>. + # The real protocol prepares |T> = T|+> on the ancilla, but T is + # non-Clifford and invisible to the fault analyzer. We prepare + # |+> as a Clifford stand-in: same error structure (H gate noise + # via p1, prep noise via p_prep), just missing the non-Clifford + # phase which doesn't affect error correlations in the DEM. + self.add_memory( + [data_label, ancilla_label], + rounds=rounds_before, + basis={data_label: "Z", ancilla_label: "X"}, + ) + # Step 2: CX(data=control, ancilla=target) — teleports T onto data. + self._operations.append( + LogicalOp( + gate_type=LogicalGateType.TRANSVERSAL_CX, + patches=[data_label, ancilla_label], + teleportation=True, + injection_type="T", + ), + ) + # Step 3: Post-CX extraction. Ancilla measured in Z-basis. + # If ancilla measures logical -1 (corrected by frame), apply S. + # This is the feed-forward decision point. + self.add_memory( + [data_label, ancilla_label], + rounds=rounds_after, + basis="Z", + ) + + def add_transversal_cx(self, control_label: str, target_label: str) -> None: + """Add a transversal CNOT between two patches. + + Applies CX between corresponding data qubits. After this: + - X-errors on control propagate to target + - Z-errors on target propagate back to control + - Weight-3 hyperedges appear in the DEM at the gate boundary + + Both patches must have the same geometry. + + Args: + control_label: Label of the control patch. + target_label: Label of the target patch. + """ + if control_label not in self._patches: + msg = f"Unknown patch '{control_label}'" + raise ValueError(msg) + if target_label not in self._patches: + msg = f"Unknown patch '{target_label}'" + raise ValueError(msg) + ctrl = self._patches[control_label] + tgt = self._patches[target_label] + if ctrl.patch.geometry.num_data != tgt.patch.geometry.num_data: + msg = ( + f"Patches must have same geometry for transversal CX. " + f"'{control_label}' has {ctrl.patch.geometry.num_data} data qubits, " + f"'{target_label}' has {tgt.patch.geometry.num_data} data qubits." + ) + raise ValueError(msg) + self._operations.append( + LogicalOp( + gate_type=LogicalGateType.TRANSVERSAL_CX, + patches=[control_label, target_label], + ), + ) + + def _snapshot_and_reset(self) -> PatchSnapshot: + """Snapshot patch states and reset for generation.""" + saved = { + label: ( + ps.x_z_swapped, + list(ps.z_obs_includes), + list(ps.x_obs_includes), + list(ps.x_entangled_with), + list(ps.z_entangled_with), + ) + for label, ps in self._patches.items() + } + for ps in self._patches.values(): + ps.x_z_swapped = False + ps.z_obs_includes = [] + ps.x_obs_includes = [] + ps.x_entangled_with = [] + ps.z_entangled_with = [] + return saved + + def _restore(self, saved: PatchSnapshot) -> None: + """Restore patch states from snapshot.""" + for label, (swapped, z_obs, x_obs, x_ent, z_ent) in saved.items(): + ps = self._patches[label] + ps.x_z_swapped = swapped + ps.z_obs_includes = z_obs + ps.x_obs_includes = x_obs + ps.x_entangled_with = x_ent + ps.z_entangled_with = z_ent + + def to_tick_circuit(self) -> object: + """Generate a PECOS TickCircuit with detector and observable annotations. + + This is the primary output — the TickCircuit is the source of truth. + Use ``to_stim()`` for Stim format (derived from TickCircuit via + ``tick_circuit_to_stim``), or ``.to_dag_circuit()`` for fault analysis. + + Returns: + TickCircuit with gates, detectors, and observables as metadata. + """ + saved = self._snapshot_and_reset() + gen = _CircuitGenerator( + patches=self._patches, + operations=self._operations, + ) + tc = gen.generate() + self._restore(saved) + return tc + + def to_dag_circuit(self) -> object: + """Generate a PECOS DagCircuit for fault analysis. + + Converts the TickCircuit to a DagCircuit, which can be used + with ``DagFaultAnalyzer`` for fault propagation analysis. + + Returns: + DagCircuit instance. + """ + return self.to_tick_circuit().to_dag_circuit() + + def to_stim( + self, + *, + p1: float = 0.0, + p2: float = 0.0, + p_meas: float = 0.0, + p_prep: float = 0.0, + ) -> str: + """Generate a Stim circuit string with correct detectors. + + Builds a TickCircuit (source of truth), then converts to Stim + format with noise injection via ``tick_circuit_to_stim()``. + + Args: + p1: Single-qubit depolarizing error rate. + p2: Two-qubit depolarizing error rate. + p_meas: Measurement error rate. + p_prep: Preparation error rate. + + Returns: + Stim circuit string. + """ + from pecos.qec.surface.circuit_builder import tick_circuit_to_stim + + tc = self.to_tick_circuit() + return tick_circuit_to_stim(tc, p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) + + def stab_coords(self) -> list[dict[str, list[tuple[float, float]]]]: + """Compute stabilizer coordinates for all patches. + + Returns a list (one per patch, in registration order) of dicts + 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``. + """ + result = [] + for ps in self._patches.values(): + geom = ps.patch.geometry + cx, cy = ps.coord_offset + x_coords = [] + for s in geom.x_stabilizers: + positions = [geom.id_to_pos[q] for q in s.data_qubits] + avg_row = sum(r for r, c in positions) / len(positions) + avg_col = sum(c for r, c in positions) / len(positions) + x_coords.append((avg_col * 2 + cx, avg_row * 2 + cy)) + z_coords = [] + for s in geom.z_stabilizers: + positions = [geom.id_to_pos[q] for q in s.data_qubits] + avg_row = sum(r for r, c in positions) / len(positions) + avg_col = sum(c for r, c in positions) / len(positions) + z_coords.append((avg_col * 2 + cx, avg_row * 2 + cy)) + result.append({"X": x_coords, "Z": z_coords}) + return result + + def build_dem( + self, + *, + p1: float = 0.001, + p2: float = 0.001, + p_meas: float = 0.001, + p_prep: float = 0.0, + ) -> str: + """Generate a DEM using the PECOS-native fault analysis pipeline. + + TickCircuit -> DagCircuit -> DagFaultAnalyzer -> DemBuilder. + No Stim dependency. + + Args: + p1: Single-qubit depolarizing error rate. + p2: Two-qubit depolarizing error rate. + p_meas: Measurement error rate. + p_prep: Preparation error rate. + + Returns: + DEM string in Stim-compatible format. + """ + from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder + + tc = self.to_tick_circuit() + dc = tc.to_dag_circuit() + analyzer = DagFaultAnalyzer(dc) + influence_map = analyzer.build_influence_map() + + det_json = tc.get_meta("detectors") + obs_json = tc.get_meta("observables") + num_meas = int(tc.get_meta("num_measurements")) + + meas_order = [] + for tick_idx in range(tc.num_ticks()): + tick = tc.get_tick(tick_idx) + for gate in tick.gate_batches(): + if gate.gate_type.name == "MZ": + meas_order.extend(int(q) for q in gate.qubits) + + dem_builder = DemBuilder(influence_map) + dem_builder = dem_builder.with_noise(p1, p2, p_meas, p_prep) + dem_builder = dem_builder.with_detectors_json(det_json) + dem_builder = dem_builder.with_observables_json(obs_json) + dem_builder = dem_builder.with_num_measurements(num_meas) + dem_builder = dem_builder.with_measurement_order(meas_order) + + return str(dem_builder.build()) + + def build_sampler_and_decoder( + self, + *, + p1: float = 0.001, + p2: float = 0.001, + p_meas: float = 0.001, + p_prep: float = 0.0, + inner_decoder: str = "pymatching", + ) -> tuple[object, object, str]: + """Build a DemSampler and OSD decoder without any string round-trip. + + Returns: + Tuple of (DemSampler, ObservableSubgraphDecoder, dem_str). + dem_str is also returned for compatibility with existing code. + """ + from pecos_rslib.qec import DagFaultAnalyzer, DemBuilder, ObservableSubgraphDecoder + + tc = self.to_tick_circuit() + dc = tc.to_dag_circuit() + analyzer = DagFaultAnalyzer(dc) + influence_map = analyzer.build_influence_map() + + det_json = tc.get_meta("detectors") + obs_json = tc.get_meta("observables") + num_meas = int(tc.get_meta("num_measurements")) + + meas_order = [] + for tick_idx in range(tc.num_ticks()): + tick = tc.get_tick(tick_idx) + for gate in tick.gate_batches(): + if gate.gate_type.name == "MZ": + meas_order.extend(int(q) for q in gate.qubits) + + dem_builder = DemBuilder(influence_map) + dem_builder = dem_builder.with_noise(p1, p2, p_meas, p_prep) + dem_builder = dem_builder.with_detectors_json(det_json) + dem_builder = dem_builder.with_observables_json(obs_json) + dem_builder = dem_builder.with_num_measurements(num_meas) + dem_builder = dem_builder.with_measurement_order(meas_order) + + dem = dem_builder.build() + sampler = dem.to_sampler() + dem_str = str(dem) + + sc = self.stab_coords() + decoder = ObservableSubgraphDecoder(dem_str, sc, inner_decoder) + + return sampler, decoder, dem_str + + def build_algorithm_descriptor( + self, + *, + p1: float = 0.001, + p2: float = 0.001, + p_meas: float = 0.001, + p_prep: float = 0.0, + buffer: int = 0, + ) -> dict: + """Extract per-segment DEMs and boundary gates for LogicalAlgorithmDecoder. + + Splits the full circuit DEM at gate boundaries. Each memory operation + becomes a segment; each transversal gate becomes a boundary gate with + Pauli frame propagation rules. + + Returns: + Dict with keys: segments, boundary_gates, num_observables, full_dem. + """ + # Build the full DEM + full_dem = self.build_dem(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) + sc = self.stab_coords() + + # Parse detector time coordinates from full DEM + det_times = {} + for raw_line in full_dem.split("\n"): + line = raw_line.strip() + if line.startswith("detector("): + paren = line.index(")") + coords = [float(x) for x in line[len("detector(") : paren].split(",")] + tokens = line[paren + 1 :].split() + for tok in tokens: + if tok.startswith("D"): + det_id = int(tok[1:]) + det_times[det_id] = coords[-1] if coords else 0.0 + + # Compute segment time boundaries from operations. + # Each MEMORY op has a number of rounds. Time coordinates are + # sequential round indices across all segments. + segments = [] + boundary_gates = [] + # Gates accumulate between consecutive MEMORY ops. + pending_gates = [] + time_cursor = 0.0 + patch_labels = list(self._patches.keys()) + num_patches = len(patch_labels) + + # Track X/Z swap state per patch for stab_coords. + # After transversal H, the X and Z stabilizer types swap. + x_z_swapped = dict.fromkeys(patch_labels, False) + + for i, op in enumerate(self._operations): + if op.gate_type == LogicalGateType.MEMORY: + # If there are pending gates, they form the boundary + # between the previous segment and this one. + if segments and pending_gates: + boundary_gates.append(pending_gates) + pending_gates = [] + elif segments: + # No gate between segments — empty boundary + boundary_gates.append([]) + pending_gates = [] + seg_start = time_cursor + seg_end = time_cursor + op.rounds + time_cursor = seg_end + + # Find detectors in this time range, extended by buffer. + # Buffer extends the window into adjacent segments for + # cross-boundary error correlation context. + buf_start = max(0, seg_start - buffer) + buf_end = seg_end + buffer + + is_last = all( + self._operations[j].gate_type != LogicalGateType.MEMORY for j in range(i + 1, len(self._operations)) + ) + if is_last: + seg_det_ids = sorted(d for d, t in det_times.items() if t >= buf_start) + else: + seg_det_ids = sorted(d for d, t in det_times.items() if buf_start <= t < buf_end) + + # Build per-segment stab_coords respecting X/Z swap state + seg_sc = [] + for label in patch_labels: + base = sc[patch_labels.index(label)] + if x_z_swapped[label]: + # Swap X and Z positions + seg_sc.append({"X": base["Z"], "Z": base["X"]}) + else: + seg_sc.append({"X": base["X"], "Z": base["Z"]}) + + segments.append( + { + "det_ids": seg_det_ids, + "num_detectors": len(seg_det_ids), + "time_start": seg_start, + "time_end": seg_end, + "stab_coords": seg_sc, + }, + ) + + elif op.gate_type == LogicalGateType.TRANSVERSAL_H: + label = op.patches[0] + idx = patch_labels.index(label) + pending_gates.append( + { + "type": "Hadamard", + "x_obs_bit": idx * 2, + "z_obs_bit": idx * 2 + 1, + }, + ) + x_z_swapped[label] = not x_z_swapped[label] + + elif op.gate_type == LogicalGateType.TRANSVERSAL_CX: + ctrl_label, tgt_label = op.patches[0], op.patches[1] + ctrl_idx = patch_labels.index(ctrl_label) + tgt_idx = patch_labels.index(tgt_label) + if op.injection_type == "T": + pending_gates.append( + { + "type": "TGateInjection", + "z_obs_bit": ctrl_idx * 2 + 1, + "ancilla_z_bit": tgt_idx * 2 + 1, + }, + ) + else: + pending_gates.append( + { + "type": "Cnot", + "ctrl_x_bit": ctrl_idx * 2, + "ctrl_z_bit": ctrl_idx * 2 + 1, + "tgt_x_bit": tgt_idx * 2, + "tgt_z_bit": tgt_idx * 2 + 1, + }, + ) + + elif op.gate_type in (LogicalGateType.TRANSVERSAL_SZ, LogicalGateType.TRANSVERSAL_SZdg): + label = op.patches[0] + idx = patch_labels.index(label) + pending_gates.append( + { + "type": "SGate", + "x_obs_bit": idx * 2, + "z_obs_bit": idx * 2 + 1, + }, + ) + + # Build per-segment sub-DEMs by filtering the full DEM. + # Each segment gets only the mechanisms involving its detectors. + seg_dems = [] + for seg in segments: + set(seg["det_ids"]) + # Build local detector index mapping + global_to_local = {g: local_id for local_id, g in enumerate(seg["det_ids"])} + + lines = [] + # Add detector coordinate declarations + for raw_line in full_dem.split("\n"): + line = raw_line.strip() + if line.startswith("detector("): + paren = line.index(")") + tokens = line[paren + 1 :].split() + for tok in tokens: + if tok.startswith("D"): + d_id = int(tok[1:]) + if d_id in global_to_local: + local = global_to_local[d_id] + coords = line[len("detector(") : paren] + lines.append(f"detector({coords}) D{local}") + + # Add error mechanisms (remap detector IDs) + for raw_line in full_dem.split("\n"): + line = raw_line.strip() + if not line.startswith("error("): + continue + tokens = line.split() + prob_tok = tokens[0] + new_tokens = [prob_tok] + has_local_det = False + for tok in tokens[1:]: + if tok.startswith("D"): + d_id = int(tok[1:]) + if d_id in global_to_local: + new_tokens.append(f"D{global_to_local[d_id]}") + has_local_det = True + elif tok.startswith("L"): + new_tokens.append(tok) + if has_local_det: + lines.append(" ".join(new_tokens)) + + seg_dems.append("\n".join(lines)) + + return { + "segments": [ + { + "dem": seg_dems[i], + "num_detectors": segments[i]["num_detectors"], + "stab_coords": segments[i]["stab_coords"], + } + for i in range(len(segments)) + ], + "boundary_gates": boundary_gates, + "num_observables": num_patches * 2, + "full_dem": full_dem, + } + + def build_decoder( + self, + *, + p1: float = 0.001, + p2: float = 0.001, + p_meas: float = 0.001, + p_prep: float = 0.0, + inner_decoder: str = "fusion_blossom_serial", + use_stim_dem: bool = True, + ) -> tuple[object, object]: + """Build an ObservableSubgraphDecoder for this circuit. + + Args: + p1: Single-qubit depolarizing error rate. + p2: Two-qubit depolarizing error rate. + p_meas: Measurement error rate. + p_prep: Preparation error rate. + inner_decoder: Decoder type for each subgraph. + use_stim_dem: If True, use Stim for DEM generation (more error + mechanisms). If False, use PECOS-native DEM pipeline. + + Returns: + Tuple of (stim.Circuit, ObservableSubgraphDecoder). + """ + import stim + from pecos_rslib.qec import ObservableSubgraphDecoder + + stim_str = self.to_stim(p1=p1, p2=p2, p_meas=p_meas, p_prep=p_prep) + circuit = stim.Circuit(stim_str) + + if use_stim_dem: + dem = circuit.detector_error_model(ignore_decomposition_failures=True) + dem_str = str(dem) + else: + 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) + return circuit, decoder + + +class _CircuitGenerator: + """Internal: generates a PECOS TickCircuit for logical circuits. + + Builds a TickCircuit with detector and observable annotations as + JSON metadata. The TickCircuit is the source of truth; Stim circuit + strings are derived from it via tick_circuit_to_stim(). + """ + + def __init__( + self, + patches: dict[str, PatchState], + operations: list[LogicalOp], + ) -> None: + from pecos_rslib.quantum import TickCircuit + + self.patches = patches + self.operations = operations + + self.tc = TickCircuit() + self._current_tick = None + self._allocated: set[int] = set() + self.meas_count = 0 + + self.stab_meas: dict[tuple[str, str, int, int, int], int] = {} + self.data_meas: dict[tuple[str, int], int] = {} + + self.segment_idx = 0 + self.next_observable_idx = 0 + self.round_time = 0.0 + + self._det_json: list[dict] = [] + self._obs_json: list[dict] = [] + + def _new_tick(self) -> object: + self._current_tick = self.tc.tick() + return self._current_tick + + def _tick(self) -> object: + if self._current_tick is None: + return self._new_tick() + return self._current_tick + + def _end_tick(self) -> None: + self._current_tick = None + + def _emit_qalloc_or_reset(self, qubits: list[int]) -> None: + t = self._tick() + new_qs = [q for q in qubits if q not in self._allocated] + old_qs = [q for q in qubits if q in self._allocated] + if new_qs: + t.qalloc(new_qs) + self._allocated.update(new_qs) + if old_qs: + t.pz(old_qs) + + def generate(self) -> object: + """Generate the TickCircuit with detector/observable metadata.""" + is_first = True + # Per-patch last memory index: for each patch, the last MEMORY + # operation that includes it. This ensures each patch gets its + # final measurement emitted in the correct segment. + last_mem_for_patch: dict[str, int] = {} + for i, op in enumerate(self.operations): + if op.gate_type == LogicalGateType.MEMORY: + for label in op.patches: + last_mem_for_patch[label] = i + + for op_idx, op in enumerate(self.operations): + if op.gate_type == LogicalGateType.MEMORY: + # A patch is "last" in this segment if this is its last memory op. + last_patches = {label for label in op.patches if last_mem_for_patch.get(label) == op_idx} + self._emit_memory_segment( + op, + is_first=is_first, + is_last=bool(last_patches), + last_patches=last_patches, + ) + is_first = False + self.segment_idx += 1 + + elif op.gate_type == LogicalGateType.TRANSVERSAL_H: + self._emit_transversal_h(op) + + elif op.gate_type == LogicalGateType.TRANSVERSAL_SZ: + self._emit_transversal_sz(op) + + elif op.gate_type == LogicalGateType.TRANSVERSAL_SZdg: + self._emit_transversal_szdg(op) + + elif op.gate_type == LogicalGateType.TRANSVERSAL_CX: + self._emit_transversal_cx(op) + + # Build detector/observable definitions with both formats: + # - "records": negative offsets (Stim compatibility, legacy) + # - "meas_ids": absolute MeasResult IDs (stable, preferred) + total = self.meas_count + det_out = [ + { + "id": d["id"], + "coords": d["coords"], + "records": [idx - total for idx in d["abs_records"]], + "meas_ids": d["abs_records"], + } + for d in self._det_json + ] + obs_out = [ + { + "id": o["id"], + "records": [idx - total for idx in o["abs_records"]], + "meas_ids": o["abs_records"], + } + for o in self._obs_json + ] + + self.tc.set_meta("detectors", json.dumps(det_out)) + self.tc.set_meta("observables", json.dumps(obs_out)) + self.tc.set_meta("num_measurements", str(total)) + return self.tc + + def _first_memory_basis(self, patch_label: str | None = None) -> str: + """Basis of the first memory segment (prep basis).""" + for op in self.operations: + if op.gate_type == LogicalGateType.MEMORY: + if patch_label and patch_label in op.per_patch_basis: + return op.per_patch_basis[patch_label] + return op.basis + return "Z" + + def _last_memory_basis(self, patch_label: str | None = None) -> str: + """Basis of the last memory segment (measurement basis).""" + last = "Z" + for op in self.operations: + if op.gate_type == LogicalGateType.MEMORY: + if patch_label and patch_label in op.per_patch_basis: + last = op.per_patch_basis[patch_label] + else: + last = op.basis + return last + + def _emit_meas(self, qubits: list[int]) -> list[int]: + self._tick().mz(qubits) + indices = list(range(self.meas_count, self.meas_count + len(qubits))) + self.meas_count += len(qubits) + return indices + + def _emit_final_meas(self, qubits: list[int]) -> list[int]: + self._tick().mz(qubits) + indices = list(range(self.meas_count, self.meas_count + len(qubits))) + self.meas_count += len(qubits) + return indices + + def _rec(self, abs_idx: int) -> int: + return abs_idx - self.meas_count + + def _data_qubits(self, patch_label: str) -> list[int]: + ps = self.patches[patch_label] + return [ps.qubit_offset + i for i in range(ps.patch.geometry.num_data)] + + def _emit_memory_segment( + self, + op: LogicalOp, + *, + is_first: bool, + is_last: bool, + last_patches: set[str] | None = None, + ) -> None: + """Emit syndrome extraction rounds for one or more patches.""" + from pecos.qec.surface.schedule import compute_cnot_schedule + + num_rounds = op.rounds + + # Precompute per-patch data + patch_info = [] + for patch_label in op.patches: + ps = self.patches[patch_label] + patch = ps.patch + geom = patch.geometry + offset = ps.qubit_offset + num_x = len(geom.x_stabilizers) + num_z = len(geom.z_stabilizers) + anc_base = offset + geom.num_data + + if ps.x_z_swapped: + x_anc_qs = [anc_base + num_x + i for i in range(num_z)] + z_anc_qs = [anc_base + i for i in range(num_x)] + current_x_stabs = geom.z_stabilizers + current_z_stabs = geom.x_stabilizers + else: + x_anc_qs = [anc_base + i for i in range(num_x)] + z_anc_qs = [anc_base + num_x + i for i in range(num_z)] + current_x_stabs = geom.x_stabilizers + current_z_stabs = geom.z_stabilizers + + patch_info.append( + { + "label": patch_label, + "ps": ps, + "geom": geom, + "offset": offset, + "num_x": num_x, + "anc_base": anc_base, + "data_qs": [offset + i for i in range(geom.num_data)], + "x_anc_qs": x_anc_qs, + "z_anc_qs": z_anc_qs, + "current_x_stabs": current_x_stabs, + "current_z_stabs": current_z_stabs, + "schedule": compute_cnot_schedule(patch), + }, + ) + + # Initialization — per-patch basis + if is_first: + t = self._new_tick() + for pi in patch_info: + self._emit_qalloc_or_reset(pi["data_qs"]) + self._end_tick() + + need_h = [] + need_hs = [] + for pi in patch_info: + pb = op.per_patch_basis.get(pi["label"], op.basis) + if pb == "X": + need_h.extend(pi["data_qs"]) + elif pb == "Y": + need_hs.extend(pi["data_qs"]) + + if need_h or need_hs: + t = self._new_tick() + if need_h: + t.h(need_h) + if need_hs: + t.h(need_hs) + self._end_tick() + if need_hs: + t = self._new_tick() + t.sz(need_hs) + self._end_tick() + + # Syndrome extraction rounds + for rnd in range(num_rounds): + # Reset ancillas + t = self._new_tick() + for pi in patch_info: + self._emit_qalloc_or_reset(pi["x_anc_qs"] + pi["z_anc_qs"]) + self._end_tick() + + # H on X-type ancillas + all_x_anc = [q for pi in patch_info for q in pi["x_anc_qs"]] + t = self._new_tick() + t.h(all_x_anc) + self._end_tick() + + # CX rounds + num_cx_rounds = max(len(pi["schedule"]) for pi in patch_info) + for cx_round_idx in range(num_cx_rounds): + all_pairs = [] + for pi in patch_info: + if cx_round_idx >= len(pi["schedule"]): + continue + for phys_type, stab_idx, data_idx in pi["schedule"][cx_round_idx]: + data_q = pi["offset"] + data_idx + if phys_type == "X": + anc_q = pi["anc_base"] + stab_idx + else: + anc_q = pi["anc_base"] + pi["num_x"] + stab_idx + currently_x = (phys_type == "X") != pi["ps"].x_z_swapped + if currently_x: + all_pairs.append((anc_q, data_q)) + else: + all_pairs.append((data_q, anc_q)) + t = self._new_tick() + if all_pairs: + t.cx(all_pairs) + self._end_tick() + + # H on X-type ancillas + t = self._new_tick() + t.h(all_x_anc) + self._end_tick() + + # Measure ancillas + t = self._new_tick() + for pi in patch_info: + x_meas = self._emit_meas(pi["x_anc_qs"]) + z_meas = self._emit_meas(pi["z_anc_qs"]) + for i, s in enumerate(pi["current_x_stabs"]): + self.stab_meas[(pi["label"], "X", s.index, self.segment_idx, rnd)] = x_meas[i] + for i, s in enumerate(pi["current_z_stabs"]): + self.stab_meas[(pi["label"], "Z", s.index, self.segment_idx, rnd)] = z_meas[i] + # Invalidate _last_round_cache since stab_meas changed + if hasattr(self, "_last_round_cache"): + del self._last_round_cache + self._end_tick() + + # Detectors + for pi in patch_info: + self._emit_round_detectors(pi["label"], rnd, is_first_segment=is_first) + + self.round_time += 1.0 + + # Final measurement: two phases so cross-patch observable + # references work (all data measurements must exist before + # any observable is emitted). + if is_last and last_patches: + final_patches = [pi for pi in patch_info if pi["label"] in last_patches] + for pi in final_patches: + self._emit_final_data_measurements(pi["label"]) + for pi in final_patches: + self._emit_final_detectors_and_observables(pi["label"]) + + def _emit_round_detectors( + self, + patch_label: str, + round_idx: int, + *, + is_first_segment: bool, + ) -> None: + """Emit detectors for one syndrome round. + + Handles three cases: + 1. First round of first segment: only basis-matching stabs are deterministic + 2. First round after a gate boundary: cross-type comparison needed + 3. Normal round: compare same-type measurements in consecutive rounds + """ + ps = self.patches[patch_label] + geom = ps.patch.geometry + seg = self.segment_idx + + for stab_type in ["X", "Z"]: + stabs = geom.x_stabilizers if stab_type == "X" else geom.z_stabilizers + for s in stabs: + curr_key = (patch_label, stab_type, s.index, seg, round_idx) + curr_idx = self.stab_meas.get(curr_key) + if curr_idx is None: + continue + + if round_idx == 0 and is_first_segment and seg == 0: + # First round of very first segment: + # Only stabilizers matching the prep basis are deterministic. + # Find the prep basis from the first memory operation. + init_basis = self._first_memory_basis(patch_label) + det_type = "Z" if init_basis == "Z" else "X" + # Account for X/Z swap + effective_type = stab_type + if ps.x_z_swapped: + effective_type = "Z" if stab_type == "X" else "X" + if effective_type == det_type: + self._add_detector( + patch_label, + stab_type, + s.index, + [curr_idx], + ) + + elif round_idx == 0 and seg > 0: + # First round after a gate boundary. + # Need to find the matching measurement from the previous segment. + self._emit_boundary_detector(patch_label, stab_type, s.index, curr_idx) + + elif round_idx > 0: + # Normal: compare with previous round in same segment + prev_key = (patch_label, stab_type, s.index, seg, round_idx - 1) + prev_idx = self.stab_meas.get(prev_key) + if prev_idx is not None: + self._add_detector( + patch_label, + stab_type, + s.index, + [curr_idx, prev_idx], + ) + + def _emit_boundary_detector( + self, + patch_label: str, + stab_type: str, + stab_index: int, + curr_meas_idx: int, + ) -> None: + """Emit a detector at a gate boundary. + + After transversal H: an X-check in the new segment corresponds to what + was a Z-check in the previous segment (and vice versa). The detector + compares the current measurement with the last measurement of the + *conjugated* type from the previous segment. + """ + self.patches[patch_label] + prev_seg = self.segment_idx - 1 + + # Find the gate that affects this specific patch at this boundary + gate_op = self._find_gate_before_segment(self.segment_idx, patch_label) + + if ( + gate_op is not None + and gate_op.gate_type == LogicalGateType.TRANSVERSAL_H + and patch_label in gate_op.patches + ): + # After H on THIS patch: X-stabs were Z-stabs, Z-stabs were X-stabs + conjugated_type = "Z" if stab_type == "X" else "X" + # Find the last round of the previous segment + prev_last_round = self._last_round_of_segment(patch_label, conjugated_type, prev_seg) + if prev_last_round is not None: + prev_key = (patch_label, conjugated_type, stab_index, prev_seg, prev_last_round) + prev_idx = self.stab_meas.get(prev_key) + if prev_idx is not None: + self._add_detector( + patch_label, + stab_type, + stab_index, + [curr_meas_idx, prev_idx], + ) + # If no previous measurement found, this stabilizer wasn't measured before + # (e.g., it's the non-deterministic type). No detector. + + elif ( + gate_op is not None + and gate_op.gate_type == LogicalGateType.TRANSVERSAL_CX + and patch_label in gate_op.patches + ): + # After CX(control, target): + # Control X-stabs: propagated to target → 3-body detector + # post_ctrl_X XOR pre_ctrl_X XOR pre_tgt_X + # Target Z-stabs: propagated back to control → 3-body detector + # post_tgt_Z XOR pre_tgt_Z XOR pre_ctrl_Z + # Control Z-stabs: unchanged → normal 2-body detector + # Target X-stabs: unchanged → normal 2-body detector + ctrl_label = gate_op.patches[0] + tgt_label = gate_op.patches[1] + is_control = patch_label == ctrl_label + + prev_last_round = self._last_round_of_segment(patch_label, stab_type, prev_seg) + if prev_last_round is None: + return # No previous measurement + + prev_key = (patch_label, stab_type, stab_index, prev_seg, prev_last_round) + prev_idx = self.stab_meas.get(prev_key) + if prev_idx is None: + return + + needs_cross_patch = (is_control and stab_type == "X") or (not is_control and stab_type == "Z") + + if needs_cross_patch: + # 3-body detector: also include the other patch's measurement + other_label = tgt_label if is_control else ctrl_label + other_last_round = self._last_round_of_segment(other_label, stab_type, prev_seg) + if other_last_round is not None: + other_key = (other_label, stab_type, stab_index, prev_seg, other_last_round) + other_idx = self.stab_meas.get(other_key) + if other_idx is not None: + self._add_detector( + patch_label, + stab_type, + stab_index, + [curr_meas_idx, prev_idx, other_idx], + ) + return + # Fall through to 2-body if cross-patch measurement not found + self._add_detector( + patch_label, + stab_type, + stab_index, + [curr_meas_idx, prev_idx], + ) + + else: + # No gate boundary — normal comparison with previous segment + prev_last_round = self._last_round_of_segment(patch_label, stab_type, prev_seg) + if prev_last_round is not None: + prev_key = (patch_label, stab_type, stab_index, prev_seg, prev_last_round) + prev_idx = self.stab_meas.get(prev_key) + if prev_idx is not None: + self._add_detector( + patch_label, + stab_type, + stab_index, + [curr_meas_idx, prev_idx], + ) + + def _find_gate_before_segment( + self, + segment_idx: int, + patch_label: str | None = None, + ) -> LogicalOp | None: + """Find the gate operation that precedes a memory segment. + + If patch_label is given, returns the gate that affects that specific + patch (checking gate_op.patches). This handles the case where multiple + gates are stacked between segments (e.g., H on A then H on B). + """ + mem_count = 0 + for i, op in enumerate(self.operations): + if op.gate_type == LogicalGateType.MEMORY: + if mem_count == segment_idx: + # Look backwards for gates + for j in range(i - 1, -1, -1): + if self.operations[j].gate_type == LogicalGateType.MEMORY: + break + if patch_label is None: + return self.operations[j] + if patch_label in self.operations[j].patches: + return self.operations[j] + return None + mem_count += 1 + return None + + def _last_round_of_segment(self, patch_label: str, stab_type: str, seg_idx: int) -> int | None: + """Find the last round index for a stabilizer type in a segment. + + Uses a cached index built on first call, then O(1) lookups. + """ + if not hasattr(self, "_last_round_cache"): + # Build cache from stab_meas keys: (patch, type, seg) → max_round + cache: dict[tuple[str, str, int], int] = {} + for patch, stype, _sidx, seg, rnd in self.stab_meas: + key = (patch, stype, seg) + if key not in cache or rnd > cache[key]: + cache[key] = rnd + self._last_round_cache = cache + return self._last_round_cache.get((patch_label, stab_type, seg_idx)) + + def _ancilla_spatial_coords( + self, + patch_label: str, + stab_type: str, + stab_index: int, + ) -> tuple[float, float]: + """Compute the spatial position of a stabilizer's ancilla. + + Returns (x, y) including the patch's coord_offset, using the + average position of the stabilizer's data qubits. + """ + ps = self.patches[patch_label] + geom = ps.patch.geometry + cx, cy = ps.coord_offset + stabs = geom.x_stabilizers if stab_type == "X" else geom.z_stabilizers + s = stabs[stab_index] + positions = [geom.id_to_pos[q] for q in s.data_qubits] + avg_row = sum(r for r, c in positions) / len(positions) + avg_col = sum(c for r, c in positions) / len(positions) + return (avg_col * 2 + cx, avg_row * 2 + cy) + + def _add_detector( + self, + patch_label: str, + stab_type: str, + stab_index: int, + meas_indices: list[int], + ) -> None: + anc_x, anc_y = self._ancilla_spatial_coords(patch_label, stab_type, stab_index) + # Store absolute indices; convert to relative offsets in generate() + self._det_json.append( + { + "id": len(self._det_json), + "coords": [anc_x, anc_y, self.round_time], + "abs_records": list(meas_indices), + }, + ) + + def _emit_transversal_h(self, op: LogicalOp) -> None: + ps = self.patches[op.patches[0]] + t = self._new_tick() + t.h(self._data_qubits(op.patches[0])) + self._end_tick() + ps.x_z_swapped = not ps.x_z_swapped + + def _emit_transversal_sz(self, op: LogicalOp) -> None: + t = self._new_tick() + t.sz(self._data_qubits(op.patches[0])) + self._end_tick() + + def _emit_transversal_szdg(self, op: LogicalOp) -> None: + t = self._new_tick() + t.szdg(self._data_qubits(op.patches[0])) + self._end_tick() + + def _emit_transversal_cx(self, op: LogicalOp) -> None: + ctrl_label, tgt_label = op.patches[0], op.patches[1] + ctrl_ps = self.patches[ctrl_label] + tgt_ps = self.patches[tgt_label] + + if ctrl_ps.x_z_swapped != tgt_ps.x_z_swapped: + msg = ( + f"Transversal CX requires same stabilizer orientation. " + f"'{ctrl_label}' swapped={ctrl_ps.x_z_swapped}, " + f"'{tgt_label}' swapped={tgt_ps.x_z_swapped}." + ) + raise ValueError(msg) + + pairs = list(zip(self._data_qubits(ctrl_label), self._data_qubits(tgt_label), strict=False)) + t = self._new_tick() + t.cx(pairs) + self._end_tick() + + if op.teleportation: + ctrl_ps.z_obs_includes.append(tgt_label) + + # Track entanglement: CX spreads X on control to target, + # and Z on target to control. + ctrl_ps.x_entangled_with.append(tgt_label) + tgt_ps.z_entangled_with.append(ctrl_label) + + def _emit_final_data_measurements(self, patch_label: str) -> None: + ps = self.patches[patch_label] + geom = ps.patch.geometry + data_qs = self._data_qubits(patch_label) + meas_basis = self._last_memory_basis(patch_label) + + if meas_basis == "X": + t = self._new_tick() + t.h(data_qs) + self._end_tick() + + t = self._new_tick() + meas_indices = self._emit_final_meas(data_qs) + self._end_tick() + for i, q in enumerate(range(geom.num_data)): + self.data_meas[(patch_label, q)] = meas_indices[i] + + def _emit_final_detectors_and_observables(self, patch_label: str) -> None: + ps = self.patches[patch_label] + geom = ps.patch.geometry + meas_basis = self._last_memory_basis(patch_label) + + if meas_basis == "Z": + final_stabs = geom.x_stabilizers if ps.x_z_swapped else geom.z_stabilizers + lookup_type = "Z" + else: + final_stabs = geom.z_stabilizers if ps.x_z_swapped else geom.x_stabilizers + lookup_type = "X" + + if ps.x_z_swapped: + logical_op = geom.logical_z if meas_basis == "X" else geom.logical_x + else: + logical_op = geom.logical_x if meas_basis == "X" else geom.logical_z + + seg = self.segment_idx + last_rnd = self._last_round_of_segment(patch_label, lookup_type, seg) + + if last_rnd is not None: + for s in final_stabs: + data_rec = [self.data_meas[(patch_label, dq)] for dq in s.data_qubits] + syn_key = (patch_label, lookup_type, s.index, seg, last_rnd) + syn_idx = self.stab_meas.get(syn_key) + if syn_idx is not None: + all_idx = [*data_rec, syn_idx] + anc_x, anc_y = self._ancilla_spatial_coords(patch_label, lookup_type, s.index) + self._det_json.append( + { + "id": len(self._det_json), + "coords": [anc_x, anc_y, self.round_time], + "abs_records": list(all_idx), + }, + ) + + if logical_op is not None: + # Check if this observable is reliable given entanglement. + # After CX(ctrl, tgt): ctrl's X is entangled with tgt, + # tgt's Z is entangled with ctrl. + # An observable is reliable if: + # - Not entangled, OR + # - The entangled partner is measured in the same basis + entangled_with = ps.x_entangled_with if meas_basis == "X" else ps.z_entangled_with + is_reliable = True + for other_label in entangled_with: + other_basis = self._last_memory_basis(other_label) + if other_basis != meas_basis: + is_reliable = False + break + + if not is_reliable: + # Skip non-reliable observables — they're physically + # non-deterministic and would cause Stim DEM errors. + # The decoder handles these through the 3-body detectors. + self.next_observable_idx += 1 + return + + obs_idx = self.next_observable_idx + self.next_observable_idx += 1 + obs_indices = [self.data_meas[(patch_label, q)] for q in logical_op.data_qubits] + + # Teleportation corrections + for other_label in ps.z_obs_includes: + other_logical = self.patches[other_label].patch.geometry.logical_z + if other_logical is not None: + for q in other_logical.data_qubits: + key = (other_label, q) + if key in self.data_meas: + obs_indices.append(self.data_meas[key]) + + self._obs_json.append( + { + "id": obs_idx, + "abs_records": list(obs_indices), + }, + ) diff --git a/python/quantum-pecos/src/pecos/qec/surface/patch.py b/python/quantum-pecos/src/pecos/qec/surface/patch.py index 322c171f4..ef48dd28b 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/patch.py +++ b/python/quantum-pecos/src/pecos/qec/surface/patch.py @@ -152,7 +152,8 @@ def _get_stabilizer_schedule_metadata(stab: Stabilizer, patch: SurfacePatch) -> stab.stab_type, stab.data_qubits, stab.is_boundary, - patch.distance, + patch.dx, + patch.dz, ) ] rounds = [int(entry["round_0based"]) for entry in entries] @@ -213,12 +214,11 @@ def _generate_layout(self) -> None: self.id_to_pos[idx] = pos def _generate_stabilizers(self) -> None: - d = min(self.dx, self.dz) - if self.rotated: - x_supports = compute_rotated_x_stabilizers(d) - z_supports = compute_rotated_z_stabilizers(d) + x_supports = compute_rotated_x_stabilizers(self.dx, self.dz) + z_supports = compute_rotated_z_stabilizers(self.dx, self.dz) else: + d = min(self.dx, self.dz) x_supports = compute_x_stabilizer_supports(d) z_supports = compute_z_stabilizer_supports(d) @@ -246,11 +246,9 @@ def _generate_stabilizers(self) -> None: self.num_z_stab = len(self.z_stabilizers) def _generate_logical_operators(self) -> None: - d = min(self.dx, self.dz) - if self.rotated: - logical_x_qubits = get_rotated_logical_x(d) - logical_z_qubits = get_rotated_logical_z(d) + logical_x_qubits = get_rotated_logical_x(self.dx, self.dz) + logical_z_qubits = get_rotated_logical_z(self.dx, self.dz) else: logical_x_qubits = tuple(i * self.dz for i in range(self.dx)) logical_z_qubits = tuple(range(self.dz)) @@ -301,27 +299,34 @@ def create( ) -> SurfacePatch: """Create a surface code patch. + Supports any positive dimensions: + - dx=dz=d (odd >= 3): standard surface code [[d^2, 1, d]] + - dx != dz: asymmetric surface code + - dx=1, dz=N: Z-repetition code [[N, 1, 1]] (N-1 X stabilizers) + - dx=N, dz=1: X-repetition code [[N, 1, 1]] (N-1 Z stabilizers) + - dx=dz=1: single physical qubit, no stabilizers + Args: - distance: Symmetric code distance (must be odd >= 3). - dx: X distance for asymmetric codes. - dz: Z distance for asymmetric codes. + distance: Symmetric code distance (>= 1). + dx: X distance (rows) for asymmetric codes (>= 1). + dz: Z distance (columns) for asymmetric codes (>= 1). orientation: Patch boundary orientation. rotated: If True (default), use the rotated layout which is more common and uses fewer qubits. If False, use the standard (non-rotated) layout. """ if distance is not None: - if distance < 3 or distance % 2 == 0: - msg = f"Distance must be odd >= 3, got {distance}" + if distance < 1: + msg = f"Distance must be >= 1, got {distance}" raise ValueError(msg) dx = dx or distance dz = dz or distance elif dx is not None and dz is not None: - if dx < 3 or dx % 2 == 0: - msg = f"dx must be odd >= 3, got {dx}" + if dx < 1: + msg = f"dx must be >= 1, got {dx}" raise ValueError(msg) - if dz < 3 or dz % 2 == 0: - msg = f"dz must be odd >= 3, got {dz}" + if dz < 1: + msg = f"dz must be >= 1, got {dz}" raise ValueError(msg) else: msg = "Must provide either distance or both dx and dz" diff --git a/python/quantum-pecos/src/pecos/qec/surface/plot.py b/python/quantum-pecos/src/pecos/qec/surface/plot.py index 2a30134f4..d02bf9345 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/plot.py +++ b/python/quantum-pecos/src/pecos/qec/surface/plot.py @@ -136,6 +136,7 @@ def _annotate_cnot_order(ax: plt.Axes, stabilizers: list, d: int) -> None: stab.data_qubits, stab.is_boundary, d, + d, ) # Compute centroid of the stabilizer diff --git a/python/quantum-pecos/src/pecos/qec/surface/schedule.py b/python/quantum-pecos/src/pecos/qec/surface/schedule.py index 410dd9ac6..8972fc934 100644 --- a/python/quantum-pecos/src/pecos/qec/surface/schedule.py +++ b/python/quantum-pecos/src/pecos/qec/surface/schedule.py @@ -31,28 +31,28 @@ from pecos.qec.surface.patch import SurfacePatch -def _classify_boundary(stab_type: str, data_qubits: tuple[int, ...], d: int) -> str: +def _classify_boundary(stab_type: str, data_qubits: tuple[int, ...], dx: int, dz: int) -> str: """Classify which boundary a weight-2 stabilizer sits on. Returns one of: 'top', 'bottom', 'left', 'right'. """ - rows = [q // d for q in data_qubits] - cols = [q % d for q in data_qubits] + rows = [q // dz for q in data_qubits] + cols = [q % dz for q in data_qubits] if stab_type == "X": # X boundaries are top and bottom if all(r == 0 for r in rows): return "top" - if all(r == d - 1 for r in rows): + if all(r == dx - 1 for r in rows): return "bottom" else: # Z boundaries are left and right - if all(c == d - 1 for c in cols): + if all(c == dz - 1 for c in cols): return "right" if all(c == 0 for c in cols): return "left" - msg = f"Cannot classify boundary for {stab_type} stab with qubits {data_qubits} (d={d})" + msg = f"Cannot classify boundary for {stab_type} stab with qubits {data_qubits} (dx={dx}, dz={dz})" raise ValueError(msg) @@ -60,7 +60,8 @@ def get_stab_schedule( stab_type: str, data_qubits: tuple[int, ...], is_boundary: bool, - d: int, + dx: int, + dz: int, ) -> list[tuple[int, int]]: """Compute the per-stabilizer CNOT schedule. @@ -69,7 +70,8 @@ def get_stab_schedule( data_qubits: Tuple of data qubit IDs. For bulk (weight 4): (TL, TR, BL, BR). For boundary (weight 2): two qubits. is_boundary: Whether this is a boundary stabilizer. - d: Code distance. + dx: Number of rows (X distance). + dz: Number of columns (Z distance). Returns: List of (round_0based, data_qubit) pairs, sorted by round. @@ -82,7 +84,7 @@ def get_stab_schedule( return [(0, tr), (1, br), (2, tl), (3, bl)] # Boundary weight-2 stabilizer - boundary = _classify_boundary(stab_type, data_qubits, d) + boundary = _classify_boundary(stab_type, data_qubits, dx, dz) if boundary == "bottom": # Bottom X: rounds 0,1 -- right first then left @@ -115,16 +117,17 @@ def compute_cnot_schedule(patch: SurfacePatch) -> list[list[tuple[str, int, int] List of 4 rounds, each a list of (stab_type, stab_index, data_qubit) tuples representing CX gates to execute in parallel. """ - d = patch.distance + dx = patch.dx + dz = patch.dz rounds: list[list[tuple[str, int, int]]] = [[] for _ in range(4)] for stab in patch.x_stabilizers: - schedule = get_stab_schedule("X", stab.data_qubits, stab.is_boundary, d) + schedule = get_stab_schedule("X", stab.data_qubits, stab.is_boundary, dx, dz) for rnd, data_q in schedule: rounds[rnd].append(("X", stab.index, data_q)) for stab in patch.z_stabilizers: - schedule = get_stab_schedule("Z", stab.data_qubits, stab.is_boundary, d) + schedule = get_stab_schedule("Z", stab.data_qubits, stab.is_boundary, dx, dz) for rnd, data_q in schedule: rounds[rnd].append(("Z", stab.index, data_q)) diff --git a/python/quantum-pecos/src/pecos/quantum/__init__.py b/python/quantum-pecos/src/pecos/quantum/__init__.py index 96bc863b6..d9c190b84 100644 --- a/python/quantum-pecos/src/pecos/quantum/__init__.py +++ b/python/quantum-pecos/src/pecos/quantum/__init__.py @@ -56,19 +56,23 @@ >>> errors = array([Pauli.X, Pauli.Y, Pauli.Z]) >>> # Create Pauli strings with convenient syntax - >>> from pecos.quantum import pauli_string - >>> ps = pauli_string("XYZ", phase=-1) # -X_0 Y_1 Z_2 + >>> from pecos.quantum import X, Z, pauli_string + >>> ps = X(0) & Z(3) + >>> from_text = pauli_string("X0 Z3") + >>> assert ps == from_text """ from __future__ import annotations +from collections.abc import Mapping as MappingABC +from collections.abc import Sequence as SequenceABC from typing import TYPE_CHECKING from pecos.quantum import commute, gate_groups from pecos.typing import INTEGER_TYPES if TYPE_CHECKING: - from collections.abc import Sequence + from collections.abc import Mapping, Sequence from pecos.typing import Integer @@ -118,6 +122,9 @@ SZdg, SZZdg, TableauWrapper, + X, + Y, + Z, adjust_tableau_string, sparse_stab, ) @@ -152,7 +159,7 @@ def pauli_string( - operators: str | Sequence[tuple[Pauli, int]] | dict[int, Pauli], + operators: str | Sequence[tuple[Pauli, int]] | Sequence[Pauli] | Mapping[int, Pauli], phase: complex = 1, ) -> PauliString: """Create a PauliString from a convenient specification. @@ -162,9 +169,10 @@ def pauli_string( Args: operators: One of the following: - - String like "XYZ" or "IXZI" (sequential qubits starting at 0) - - List of (Pauli, qubit_index) tuples - - Dict mapping qubit_index -> Pauli + - Sparse string like "X0 Z2" or dense string like "XIZ" + - Sequence of (Pauli, qubit_index) tuples + - Sequence of Pauli values for implicit qubits 0, 1, 2, ... + - Mapping from qubit_index -> Pauli phase: Phase factor, one of: - 1 or +1: Plus one (default) - -1: Minus one @@ -175,11 +183,19 @@ def pauli_string( PauliString object Examples: - >>> from pecos.quantum import Pauli, pauli_string + >>> from pecos.quantum import Pauli, X, Z, pauli_string - >>> # From string (sequential qubits) - >>> ps = pauli_string("XYZ") - >>> print(ps) # X_0 Y_1 Z_2 + >>> # Constructor syntax is preferred for ordinary code + >>> ps = X(0) & Z(2) + >>> print(ps) # X_0 Z_2 + + >>> # From sparse string (explicit qubit indices) + >>> ps = pauli_string("X0 Z2") + >>> print(ps) # X_0 Z_2 + + >>> # From dense string (character position is qubit index) + >>> ps = pauli_string("XIZ") + >>> print(ps) # X_0 Z_2 >>> # From list of (Pauli, qubit) tuples >>> ps = pauli_string([(Pauli.X, 0), (Pauli.Z, 2)]) @@ -229,14 +245,14 @@ def pauli_string( paulis = ps.get_paulis() return PauliString(paulis, phase=phase_code) return ps - if isinstance(operators, dict): - # Dict format - convert to list + if isinstance(operators, MappingABC): + # Mapping format - convert to list paulis = [(pauli, qubit) for qubit, pauli in sorted(operators.items())] return PauliString(paulis, phase=phase_code) - if isinstance(operators, list): - # Already in list format - return PauliString(operators, phase=phase_code) - msg = f"Invalid operators type: {type(operators)}. Must be str, dict, or list" + if isinstance(operators, SequenceABC): + # PyO3 constructor accepts lists, so normalize all Python sequences. + return PauliString(list(operators), phase=phase_code) + msg = f"Invalid operators type: {type(operators)}. Must be str, mapping, or sequence" raise TypeError(msg) @@ -295,6 +311,9 @@ def pauli_string( "TickHandle", "TickMeasureHandle", "TickPrepHandle", + "X", + "Y", + "Z", "adjust_tableau_string", "commute", "gate_groups", diff --git a/python/quantum-pecos/src/pecos/quantum_info.py b/python/quantum-pecos/src/pecos/quantum_info.py new file mode 100644 index 000000000..5cc313105 --- /dev/null +++ b/python/quantum-pecos/src/pecos/quantum_info.py @@ -0,0 +1,70 @@ +"""Quantum-information channel representations and measures. + +This module re-exports the Rust-backed implementations from +``pecos_rslib.quantum_info``. Computation and validation happen in Rust; this +file only provides the public Python import location. +""" + +from __future__ import annotations + +from pecos_rslib.quantum_info import ( + ChiMatrix, + ChoiMatrix, + KrausOps, + PauliChannel, + ProcessTomographyDesign, + Ptm, + Stinespring, + SuperOp, + average_gate_fidelity, + entropy, + gate_error, + hellinger_distance, + hellinger_fidelity, + logarithmic_negativity, + matrix_unit_basis, + negativity, + partial_trace_qubits, + partial_trace_subsystems, + pauli_channel_diamond_distance, + pauli_channel_diamond_norm, + process_fidelity, + purity, + random_density_matrix, + random_quantum_channel, + schmidt_decomposition, + shannon_entropy, + state_fidelity, + state_fidelity_with_density_matrix, +) + +__all__ = [ + "ChiMatrix", + "ChoiMatrix", + "KrausOps", + "PauliChannel", + "ProcessTomographyDesign", + "Ptm", + "Stinespring", + "SuperOp", + "average_gate_fidelity", + "entropy", + "gate_error", + "hellinger_distance", + "hellinger_fidelity", + "logarithmic_negativity", + "matrix_unit_basis", + "negativity", + "partial_trace_qubits", + "partial_trace_subsystems", + "pauli_channel_diamond_distance", + "pauli_channel_diamond_norm", + "process_fidelity", + "purity", + "random_density_matrix", + "random_quantum_channel", + "schmidt_decomposition", + "shannon_entropy", + "state_fidelity", + "state_fidelity_with_density_matrix", +] diff --git a/python/quantum-pecos/src/pecos/typing.py b/python/quantum-pecos/src/pecos/typing.py index 6ac4e47fd..022297e17 100644 --- a/python/quantum-pecos/src/pecos/typing.py +++ b/python/quantum-pecos/src/pecos/typing.py @@ -23,12 +23,143 @@ from __future__ import annotations -from typing import TYPE_CHECKING, Generic, Protocol, TypeAlias, TypedDict, TypeVar +from typing import TYPE_CHECKING, Generic, Literal, Protocol, TypeAlias, TypedDict, TypeVar import pecos_rslib as prs +from phir.model import ( + Barrier as _Barrier, +) +from phir.model import ( + Comment as _Comment, +) +from phir.model import ( + COp as _COp, +) +from phir.model import ( + CVarDefine as _CVarDefine, +) +from phir.model import ( + ExportVar as _ExportVar, +) +from phir.model import ( + FFCall as _FFCall, +) +from phir.model import ( + IfBlock as _IfBlock, +) +from phir.model import ( + MOpType as _MOpType, +) +from phir.model import ( + Op as _Op, +) +from phir.model import ( + PHIRModel as _PHIRModel, +) +from phir.model import ( + QOp as _QOp, +) +from phir.model import ( + QParBlock as _QParBlock, +) +from phir.model import ( + QVarDefine as _QVarDefine, +) +from phir.model import ( + SeqBlock as _SeqBlock, +) +from pydantic import model_validator + + +class _PecosCVarDefine(_CVarDefine): + """CVarDefine extended with 8-bit and 16-bit integer data types. + + The upstream PHIR spec's ``CVarDefine`` only accepts ``i32``, ``i64``, + ``u32``, ``u64``. PECOS additionally supports ``i8``, ``u8``, ``i16``, + ``u16`` for classical registers. The parent class's ``check_size`` + validator covers 32 and 64-bit cases; this subclass adds the matching + check for 8-bit and 16-bit sizes. + """ + + data_type: Literal["i8", "i16", "i32", "i64", "u8", "u16", "u32", "u64"] # type: ignore[assignment] + + @model_validator(mode="after") + def _check_size_small(self) -> _PecosCVarDefine: + """Check that ``size`` fits within 8-bit and 16-bit data types.""" + msg = "`size` is greater than what `data_type` can handle" + if self.size: + match self.data_type: + case "i8" | "u8": + if self.size > 8: + raise ValueError(msg) + case "i16" | "u16": + if self.size > 16: + raise ValueError(msg) + return self + + +_PecosDataMgmt: TypeAlias = _PecosCVarDefine | _QVarDefine | _ExportVar + + +class ResultCOp(_Op): + """PECOS-specific ``Result`` classical operation. + + Copies the value of internal classical registers to external result + variables, creating the destination variable if needed. Used by the + PECOS ``HybridEngine`` to transmit measurement bits between the inner + and outer classical interpreters. + + Example: + ``{"cop": "Result", "args": ["m"], "returns": ["c"]}`` + + This operation is a PECOS extension, not part of the upstream PHIR + specification. + """ + + cop: Literal["Result"] + args: list[str] + returns: list[str] + + +_PecosOpType: TypeAlias = _FFCall | _COp | ResultCOp | _QOp | _MOpType | _Barrier + + +class _PecosSeqBlock(_SeqBlock): + """SeqBlock extended with PECOS-specific classical operations.""" + + ops: list[_PecosOpType | _PecosBlockType] # type: ignore[assignment] + + +class _PecosIfBlock(_IfBlock): + """IfBlock extended with PECOS-specific classical operations.""" + + true_branch: list[_PecosOpType | _PecosBlockType] # type: ignore[assignment] + false_branch: list[_PecosOpType | _PecosBlockType] | None = None # type: ignore[assignment] + + +_PecosBlockType: TypeAlias = _PecosSeqBlock | _QParBlock | _PecosIfBlock +_PecosCmd: TypeAlias = _PecosDataMgmt | _PecosOpType | _PecosBlockType | _Comment + + +class PhirModel(_PHIRModel): + """PHIR model extended with PECOS-specific classical operations. + + Adds support for the ``Result`` cop used by PECOS ``HybridEngine`` to + map internal measurement registers to external result variables. Fully + backwards-compatible with upstream PHIR programs. + + The upstream ``phir.model.PHIRModel`` rejects programs containing + ``Result`` cops because ``Result`` is not in the PHIR specification. + Use this class (or ``pecos.typing.PhirModel``) when validating + programs that may contain PECOS extensions. + """ + + ops: list[_PecosCmd] # type: ignore[assignment] + -# Import external PHIR model with consistent naming -from phir.model import PHIRModel as PhirModel +_PecosSeqBlock.model_rebuild() +_PecosIfBlock.model_rebuild() +PhirModel.model_rebuild() # Type variable for dtype (used with Array[DType]) DType = TypeVar("DType") @@ -139,7 +270,7 @@ class ErrorParams(TypedDict, total=False): p2: float p2_mem: float | None p_meas: float | tuple[float, ...] - p_init: float + p_prep: float scale: float noiseless_qubits: set[int] diff --git a/python/quantum-pecos/tests/conftest.py b/python/quantum-pecos/tests/conftest.py index ecfcd5563..7076b0d55 100644 --- a/python/quantum-pecos/tests/conftest.py +++ b/python/quantum-pecos/tests/conftest.py @@ -1,38 +1,7 @@ -# 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. +"""Shared pytest setup for the Python test suite.""" -"""Test configuration and shared fixtures.""" - -import pytest - -# Configure matplotlib to use non-interactive backend for tests (if available) -# This must be done before importing matplotlib.pyplot to avoid GUI backend issues on Windows -try: - import matplotlib as mpl - - mpl.use("Agg") -except ImportError: - # matplotlib is optional - only needed for visualization tests - pass - -# Note: llvmlite functionality is now always available via Rust (pecos_rslib.ir and pecos_rslib.binding) -# No need for conditional test skipping - - -def pytest_configure(config: pytest.Config) -> None: - """Register test-tree-local markers used by direct pytest invocations.""" - config.addinivalue_line( - "markers", - ( - "slow: mark tests that provide extra integration coverage but are " - "excluded from the default fast Python test lane" - ), - ) +# The test tree contains ``tests/pecos``. Pytest can import tests from that +# directory as a namespace package named ``pecos`` when an individual file is run +# in isolation. Import the installed/source package first so later +# ``import pecos`` statements resolve to the public PECOS package. +import pecos diff --git a/python/quantum-pecos/tests/docs/conftest.py b/python/quantum-pecos/tests/docs/conftest.py index 354dada23..7a5ed3ca1 100644 --- a/python/quantum-pecos/tests/docs/conftest.py +++ b/python/quantum-pecos/tests/docs/conftest.py @@ -63,7 +63,7 @@ def cuda_check() -> bool: @pytest.fixture(autouse=True) -def restore_cwd(): # noqa: ANN201 +def restore_cwd(): """Restore the current working directory after each test. Some tests (e.g., WASM examples) change the working directory, diff --git a/python/quantum-pecos/tests/docs/rust_crate/Cargo.lock b/python/quantum-pecos/tests/docs/rust_crate/Cargo.lock index 04256d57e..9b4910bed 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/Cargo.lock +++ b/python/quantum-pecos/tests/docs/rust_crate/Cargo.lock @@ -590,6 +590,15 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "cpp_demangle" +version = "0.4.5" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f2bb79cb74d735044c972aae58ed0aaa9a837e85b01106a54c39e42e97f62253" +dependencies = [ + "cfg-if", +] + [[package]] name = "cpufeatures" version = "0.2.17" @@ -610,27 +619,27 @@ dependencies = [ [[package]] name = "cranelift-assembler-x64" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "046d4b584c3bb9b5eb500c8f29549bec36be11000f1ba2a927cef3d1a9875691" +checksum = "f8628cc4ba7f88a9205a7ee42327697abc61195a1e3d92cfae172d6a946e722e" dependencies = [ "cranelift-assembler-x64-meta", ] [[package]] name = "cranelift-assembler-x64-meta" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b9b194a7870becb1490366fc0ae392ccd188065ff35f8391e77ac659db6fb977" +checksum = "d582754487e6c9a065a91c42ccf1bdd8d5977af33468dac5ae9bec0ce88acb3e" dependencies = [ "cranelift-srcgen", ] [[package]] name = "cranelift-bforest" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "bb6a4ab44c6b371e661846b97dab687387a60ac4e2f864e2d4257284aad9e889" +checksum = "fb59c81ace12ee7c33074db7903d4d75d1f40b28cd3e8e6f491de57b29129eb9" dependencies = [ "cranelift-entity", "wasmtime-internal-core", @@ -638,9 +647,9 @@ dependencies = [ [[package]] name = "cranelift-bitset" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b8b7a44150c2f471a94023482bda1902710746e4bed9f9973d60c5a94319b06d" +checksum = "f25c06993a681be9cf3140798a3d4ac5bec955e7444416a2fdc87fda8567285d" dependencies = [ "serde", "serde_derive", @@ -649,9 +658,9 @@ dependencies = [ [[package]] name = "cranelift-codegen" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "01b06598133b1dd76758b8b95f8d6747c124124aade50cea96a3d88b962da9fa" +checksum = "27b61f95c5a211918f5d336254a61a488b36a5818de47a868e8c4658dce9cccc" dependencies = [ "bumpalo", "cranelift-assembler-x64", @@ -677,9 +686,9 @@ dependencies = [ [[package]] name = "cranelift-codegen-meta" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6190e2e7bcf0a678da2f715363d34ed530fedf7a2f0ab75edaefef72a70465ff" +checksum = "0b85aa822fce72080d041d7c2cf7c3f5c6ecdea7afae68379ba4ef85269c4fa5" dependencies = [ "cranelift-assembler-x64-meta", "cranelift-codegen-shared", @@ -690,24 +699,24 @@ dependencies = [ [[package]] name = "cranelift-codegen-shared" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f583cf203d1aa8b79560e3b01f929bdacf9070b015eec4ea9c46e22a3f83e4a0" +checksum = "833eb9fc89326cd072cc19e96892f09b5692c0dfe17cd4da2858ba30c2cd85c0" [[package]] name = "cranelift-control" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "803159df35cc398ae54473c150b16d6c77e92ab2948be638488de126a3328fbc" +checksum = "9d005320f487e6e8a3edcc7f2fd4f43fcc9946d1013bf206ea649789ac1617fc" dependencies = [ "arbitrary", ] [[package]] name = "cranelift-entity" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3109e417257082d88087f5bcce677525bdaa8322b88dd7f175ed1a1fd41d546c" +checksum = "5e62ef34c6e720f347a79ece043e8584e242d168911da640bac654a33a6aaaf5" dependencies = [ "cranelift-bitset", "serde", @@ -717,9 +726,9 @@ dependencies = [ [[package]] name = "cranelift-frontend" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "14db6b0e0e4994c581092df78d837be2072578f7cb2528f96a6cf895e56dee63" +checksum = "dfa2ad00399dd47e7e7e33cb1dc23b0e39ed9dcd01e8f026fc37af91655031b8" dependencies = [ "cranelift-codegen", "log", @@ -729,15 +738,15 @@ dependencies = [ [[package]] name = "cranelift-isle" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ec66ea5025c7317383699778282ac98741d68444f956e3b1d7b62f12b7216e67" +checksum = "02c51975ed217b4e8e5a7fd11e9ec83a96104bdff311dddcb505d1d8a9fd7fc6" [[package]] name = "cranelift-native" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "373ade56438e6232619d85678477d0a88a31b3581936e0503e61e96b546b0800" +checksum = "f9b1889e00da9729d8f8525f3c12998ded86ea709058ff844ebe00b97548de0e" dependencies = [ "cranelift-codegen", "libc", @@ -746,9 +755,9 @@ dependencies = [ [[package]] name = "cranelift-srcgen" -version = "0.130.1" +version = "0.131.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ef53619d3cd5c78fd998c6d9420547af26b72e6456f94c2a8a2334cb76b42baa" +checksum = "d5a8f82fd5124f009f72167e60139245cd3b56cfd4b53050f22110c48c5f4da1" [[package]] name = "crc" @@ -871,7 +880,7 @@ checksum = "b0f4697d190a142477b16aef7da8a99bfdc41e7e8b1687583c0d23a79c7afc1e" dependencies = [ "cc", "codespan-reporting", - "indexmap 2.13.0", + "indexmap 2.14.0", "proc-macro2", "quote", "scratch", @@ -886,7 +895,7 @@ checksum = "d0956799fa8678d4c50eed028f2de1c0552ae183c76e976cf7ca8c4e36a7c328" dependencies = [ "clap", "codespan-reporting", - "indexmap 2.13.0", + "indexmap 2.14.0", "proc-macro2", "quote", "syn", @@ -904,7 +913,7 @@ version = "1.0.194" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e6acc6b5822b9526adfb4fc377b67128fdd60aac757cc4a741a6278603f763cf" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "proc-macro2", "quote", "syn", @@ -1076,7 +1085,7 @@ dependencies = [ "libc", "option-ext", "redox_users", - "windows-sys 0.59.0", + "windows-sys 0.60.2", ] [[package]] @@ -1436,7 +1445,7 @@ checksum = "0bf7f043f89559805f8c7cacc432749b2fa0d0a0a9ee46ce47164ed5ba7f126c" dependencies = [ "fnv", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "stable_deref_trait", ] @@ -1579,6 +1588,15 @@ dependencies = [ "serde_core", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" +dependencies = [ + "foldhash 0.2.0", +] + [[package]] name = "heck" version = "0.4.1" @@ -1677,7 +1695,7 @@ dependencies = [ "enum_dispatch", "html-escape", "hugr-model", - "indexmap 2.13.0", + "indexmap 2.14.0", "itertools 0.14.0", "ordered-float", "pastey", @@ -1731,7 +1749,7 @@ dependencies = [ "bumpalo", "capnp", "derive_more 2.1.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "itertools 0.14.0", "ordered-float", "pest", @@ -1983,12 +2001,12 @@ dependencies = [ [[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 0.16.1", + "hashbrown 0.17.1", "serde", "serde_core", ] @@ -2537,13 +2555,13 @@ dependencies = [ [[package]] name = "object" -version = "0.38.1" +version = "0.39.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "271638cd5fa9cca89c4c304675ca658efc4e64a66c716b7cfe1afb4b9611dbbc" +checksum = "2e5a6c098c7a3b6547378093f5cc30bc54fd361ce711e05293a5cc589562739b" dependencies = [ "crc32fast", - "hashbrown 0.16.1", - "indexmap 2.13.0", + "hashbrown 0.17.1", + "indexmap 2.14.0", "memchr", ] @@ -2706,6 +2724,8 @@ version = "0.2.0-dev.0" dependencies = [ "anyhow", "ndarray 0.17.2", + "pecos-random", + "rayon", "thiserror 2.0.18", ] @@ -2727,6 +2747,7 @@ dependencies = [ "pecos-decoder-core", "pecos-decoders", "pecos-engines", + "pecos-foreign", "pecos-hugr", "pecos-neo", "pecos-num", @@ -2759,6 +2780,21 @@ dependencies = [ "serde_json", ] +[[package]] +name = "pecos-foreign" +version = "0.2.0-dev.0" +dependencies = [ + "dirs", + "libloading 0.9.0", + "log", + "ndarray 0.17.2", + "pecos-core", + "pecos-decoder-core", + "pecos-engines", + "pecos-random", + "pecos-simulators", +] + [[package]] name = "pecos-hugr" version = "0.2.0-dev.0" @@ -2908,12 +2944,14 @@ dependencies = [ "ndarray 0.17.2", "pecos-core", "pecos-decoder-core", + "pecos-num", "pecos-quantum", "pecos-random", "pecos-simulators", "rand 0.10.1", "rand_core 0.10.0", "rayon", + "serde_json", "smallvec", "thiserror 2.0.18", "wide 1.2.0", @@ -2972,6 +3010,8 @@ dependencies = [ "num-complex", "pecos-core", "pecos-num", + "pecos-random", + "serde_json", "smallvec", "tket", ] @@ -2990,6 +3030,7 @@ dependencies = [ name = "pecos-simulators" version = "0.2.0-dev.0" dependencies = [ + "nalgebra", "num-complex", "pecos-core", "pecos-quantum", @@ -3065,7 +3106,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b4c5cc86750666a3ed20bdaf5ca2a0344f9c67674cae0515bec2da16fbaa47db" dependencies = [ "fixedbitset 0.4.2", - "indexmap 2.13.0", + "indexmap 2.14.0", ] [[package]] @@ -3076,7 +3117,7 @@ checksum = "8701b58ea97060d5e5b155d383a69952a60943f0e6dfe30b04c287beb0b27455" dependencies = [ "fixedbitset 0.5.7", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", "serde", ] @@ -3199,7 +3240,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "93980406f12d9f8140ed5abe7155acb10bb1e69ea55c88960b9c2f117445ef96" dependencies = [ "equivalent", - "indexmap 2.13.0", + "indexmap 2.14.0", "serde", ] @@ -3236,9 +3277,9 @@ dependencies = [ [[package]] name = "pulley-interpreter" -version = "43.0.1" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "010dec3755eb61b2f1051ecb3611b718460b7a74c131e474de2af20a845938af" +checksum = "b9326e3a0093d170582cf64ed9e4cf253b8aac155ec4a294ff62330450bbf094" dependencies = [ "cranelift-bitset", "log", @@ -3248,9 +3289,9 @@ dependencies = [ [[package]] name = "pulley-macros" -version = "43.0.1" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ad360c32e85ca4b083ac0e2b6856e8f11c3d5060dafa7d5dc57b370857fa3018" +checksum = "00c6433917e3789605b1f4cd2a589f637ff17212344e7fa5ba99544625ba52c7" dependencies = [ "proc-macro2", "quote", @@ -3679,6 +3720,12 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "rustc-demangle" +version = "0.1.27" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b50b8869d9fc858ce7266cce0194bd74df58b9d0e3f6df3a9fc8eb470d95c09d" + [[package]] name = "rustc-hash" version = "2.1.1" @@ -4037,7 +4084,7 @@ dependencies = [ "chrono", "hex", "indexmap 1.9.3", - "indexmap 2.13.0", + "indexmap 2.14.0", "schemars 0.9.0", "schemars 1.2.1", "serde_core", @@ -4428,7 +4475,7 @@ dependencies = [ "fxhash", "hugr", "hugr-core", - "indexmap 2.13.0", + "indexmap 2.14.0", "itertools 0.14.0", "lazy_static", "num-rational", @@ -4473,7 +4520,7 @@ dependencies = [ "derive_more 2.1.1", "hugr", "hugr-core", - "indexmap 2.13.0", + "indexmap 2.14.0", "itertools 0.14.0", "lazy_static", "serde", @@ -4514,7 +4561,7 @@ version = "1.1.0+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f8195ca05e4eb728f4ba94f3e3291661320af739c4e43779cbdfae82ab239fcc" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "serde_core", "serde_spanned", "toml_datetime", @@ -4538,7 +4585,7 @@ version = "0.25.8+spec-1.1.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "16bff38f1d86c47f9ff0647e6838d7bb362522bdf44006c7068c2b1e606f1f3c" dependencies = [ - "indexmap 2.13.0", + "indexmap 2.14.0", "toml_datetime", "toml_parser", "winnow", @@ -4881,12 +4928,22 @@ dependencies = [ [[package]] name = "wasm-encoder" -version = "0.245.1" +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" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3f9dca005e69bf015e45577e415b9af8c67e8ee3c0e38b5b0add5aa92581ed5c" +checksum = "ac92cf547bc18d27ecc521015c08c353b4f18b84ab388bb6d1b6b682c620d9b6" dependencies = [ "leb128fmt", - "wasmparser 0.245.1", + "wasmparser 0.248.0", ] [[package]] @@ -4896,7 +4953,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bb0e353e6a2fbdc176932bbaab493762eb1255a7900fe0fea1a2f96c296cc909" dependencies = [ "anyhow", - "indexmap 2.13.0", + "indexmap 2.14.0", "wasm-encoder 0.244.0", "wasmparser 0.244.0", ] @@ -4909,39 +4966,50 @@ checksum = "47b807c72e1bac69382b3a6fb3dbe8ea4c0ed87ff5629b8685ae6b9a611028fe" dependencies = [ "bitflags", "hashbrown 0.15.5", - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", ] [[package]] name = "wasmparser" -version = "0.245.1" +version = "0.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "4f08c9adee0428b7bddf3890fc27e015ac4b761cc608c822667102b8bfd6995e" +checksum = "71cde4757396defafd25417cfb36aa3161027d06d865b0c24baaae229aac005d" dependencies = [ "bitflags", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "semver", "serde", ] +[[package]] +name = "wasmparser" +version = "0.248.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "aa4439c5eee9df71ee0c6efb37f63b1fcb1fec38f85f5142c54e7ed05d33091a" +dependencies = [ + "bitflags", + "indexmap 2.14.0", + "semver", +] + [[package]] name = "wasmprinter" -version = "0.245.1" +version = "0.246.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "5f41517a3716fbb8ccf46daa9c1325f760fcbff5168e75c7392288e410b91ac8" +checksum = "6e41f7493ba994b8a779430a4c25ff550fd5a40d291693af43a6ef48688f00e3" dependencies = [ "anyhow", "termcolor", - "wasmparser 0.245.1", + "wasmparser 0.246.2", ] [[package]] name = "wasmtime" -version = "43.0.1" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "ce205cd643d661b5ba5ba4717e13730262e8cdbc8f2eacbc7b906d45c1a74026" +checksum = "372db8bbad8ec962038101f75ab2c3ffcd18797d7d3ae877a58ab9873cd0c4bd" dependencies = [ "addr2line", "async-trait", @@ -4962,7 +5030,7 @@ dependencies = [ "serde_derive", "smallvec", "target-lexicon", - "wasmparser 0.245.1", + "wasmparser 0.246.2", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-cranelift", @@ -4977,36 +5045,38 @@ dependencies = [ [[package]] name = "wasmtime-environ" -version = "43.0.1" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "0b8b78abf3677d4a0a5db82e5015b4d085ff3a1b8b472cbb8c70d4b769f019ce" +checksum = "1e15aa0d1545e48d9b25ca604e9e27b4cd6d5886d30ac5787b57b3a2daf85b57" dependencies = [ "anyhow", + "cpp_demangle", "cranelift-bforest", "cranelift-bitset", "cranelift-entity", "gimli", "hashbrown 0.16.1", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "object", "postcard", + "rustc-demangle", "serde", "serde_derive", "sha2 0.10.9", "smallvec", "target-lexicon", - "wasm-encoder 0.245.1", - "wasmparser 0.245.1", + "wasm-encoder 0.246.2", + "wasmparser 0.246.2", "wasmprinter", "wasmtime-internal-core", ] [[package]] name = "wasmtime-internal-core" -version = "43.0.1" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22632b187e1b0716f1b9ac57ad29013bed33175fcb19e10bb6896126f82fac67" +checksum = "8f2c7fa6523647262bfb4095dbdf4087accefe525813e783f81a0c682f418ce4" dependencies = [ "hashbrown 0.16.1", "libm", @@ -5015,9 +5085,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-cranelift" -version = "43.0.1" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "8b3ca07b3e0bb3429674b173b5800577719d600774dd81bff58f775c0aaa64ee" +checksum = "98c032f422e39061dfc43f32190c0a3526b04161ec4867f362958f3fe9d1fe29" dependencies = [ "cfg-if", "cranelift-codegen", @@ -5033,7 +5103,7 @@ dependencies = [ "smallvec", "target-lexicon", "thiserror 2.0.18", - "wasmparser 0.245.1", + "wasmparser 0.246.2", "wasmtime-environ", "wasmtime-internal-core", "wasmtime-internal-unwinder", @@ -5042,9 +5112,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-fiber" -version = "43.0.1" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20c8b2c9704eb1f33ead025ec16038277ccb63d0a14c31e99d5b765d7c36da55" +checksum = "d8dd76d80adf450cc260ba58f23c28030401930b19149695b1d121f7d621e791" dependencies = [ "cc", "cfg-if", @@ -5057,9 +5127,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-debug" -version = "43.0.1" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d950310d07391d34369f62c48336ebb14eacbd4d6f772bb5f349c24e838e0664" +checksum = "ab453cc600b28ee5d3f9495aa6d4cb2c81eda40903e9287296b548fba8b2391d" dependencies = [ "cc", "wasmtime-internal-versioned-export-macros", @@ -5067,9 +5137,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-jit-icache-coherence" -version = "43.0.1" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "3606662c156962d096be3127b8b8ae8ee2f8be3f896dad29259ff01ddb64abfd" +checksum = "6a1859e920871515d324fb9757c3e448d6ed1512ca6ccdff14b6e016505d6ada" dependencies = [ "cfg-if", "libc", @@ -5079,9 +5149,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-unwinder" -version = "43.0.1" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "75eef0747e52dc545b075f64fd0e0cc237ae738e641266b1970e07e2d744bc32" +checksum = "f1dfe405bd6adb1386d935a30f16a236bd4ef0d3c383e7cbbab98d063c9d9b73" dependencies = [ "cfg-if", "cranelift-codegen", @@ -5092,9 +5162,9 @@ dependencies = [ [[package]] name = "wasmtime-internal-versioned-export-macros" -version = "43.0.1" +version = "44.0.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d8b0a5dab02a8fb527f547855ecc0e05f9fdc3d5bd57b8b080349408f9a6cece" +checksum = "2a9b9165fc45d42c81edfe3e9cb458e58720594ad5db6553c4079ea041a4a581" dependencies = [ "proc-macro2", "quote", @@ -5103,22 +5173,22 @@ dependencies = [ [[package]] name = "wast" -version = "245.0.1" +version = "248.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "28cf1149285569120b8ce39db8b465e8a2b55c34cbb586bd977e43e2bc7300bf" +checksum = "acc54622ed5a5cddafcdf152043f9d4aed54d4a653d686b7dfe874809fca99d7" dependencies = [ "bumpalo", "leb128fmt", "memchr", "unicode-width", - "wasm-encoder 0.245.1", + "wasm-encoder 0.248.0", ] [[package]] name = "wat" -version = "1.245.1" +version = "1.248.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "cd48d1679b6858988cb96b154dda0ec5bbb09275b71db46057be37332d5477be" +checksum = "d75cd9e510603909748e6ebab89f27cd04472c1d9d85a3c88a7a6fc51a1a7934" dependencies = [ "wast", ] @@ -5508,7 +5578,7 @@ checksum = "b7c566e0f4b284dd6561c786d9cb0142da491f46a9fbed79ea69cdad5db17f21" dependencies = [ "anyhow", "heck 0.5.0", - "indexmap 2.13.0", + "indexmap 2.14.0", "prettyplease", "syn", "wasm-metadata", @@ -5539,7 +5609,7 @@ checksum = "9d66ea20e9553b30172b5e831994e35fbde2d165325bec84fc43dbf6f4eb9cb2" dependencies = [ "anyhow", "bitflags", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "serde", "serde_derive", @@ -5558,7 +5628,7 @@ checksum = "ecc8ac4bc1dc3381b7f59c34f00b67e18f910c2c0f50015669dde7def656a736" dependencies = [ "anyhow", "id-arena", - "indexmap 2.13.0", + "indexmap 2.14.0", "log", "semver", "serde", diff --git a/python/quantum-pecos/tests/docs/rust_crate/Cargo.toml b/python/quantum-pecos/tests/docs/rust_crate/Cargo.toml index cd448a9ef..847ca2bb2 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/Cargo.toml +++ b/python/quantum-pecos/tests/docs/rust_crate/Cargo.toml @@ -17,6 +17,7 @@ pecos-num = { path = "../../../../../crates/pecos-num" } pecos-qec = { path = "../../../../../crates/pecos-qec" } pecos-decoders = { path = "../../../../../crates/pecos-decoders", features = ["ldpc"] } pecos-decoder-core = { path = "../../../../../crates/pecos-decoder-core" } +pecos-foreign = { path = "../../../../../crates/pecos-foreign" } pecos-random = { path = "../../../../../crates/pecos-random" } pecos-qasm = { path = "../../../../../crates/pecos-qasm" } pecos-programs = { path = "../../../../../crates/pecos-programs" } diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/development_foreign_plugins.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/development_foreign_plugins.rs new file mode 100644 index 000000000..aa983bbc2 --- /dev/null +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/development_foreign_plugins.rs @@ -0,0 +1,14 @@ +//! Auto-generated Rust tests from development/foreign-plugins.md +//! DO NOT EDIT - Generated by scripts/docs/generate_doc_tests.py +#![allow(unused_imports, unused_variables, unused_mut, unused_assignments, dead_code, non_snake_case)] + + +#[test] +fn test_development_foreign_plugins_rust_3() { + use pecos_foreign::discovery::discover_plugins; + let plugins = discover_plugins(); // scans ~/.pecos/plugins/ + for plugin in &plugins { + println!("Loaded: {} (decoder: {}, simulator: {})", + plugin.name, plugin.decoder.is_some(), plugin.simulator.is_some()); + } +} diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_circuit_representation.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_circuit_representation.rs index 050209522..4862d6ab2 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_circuit_representation.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_circuit_representation.rs @@ -8,7 +8,7 @@ fn test_user_guide_circuit_representation_rust_1() { use pecos::core::{Gate, QubitId}; use pecos::dag::DAG; use pecos::digraph::DiGraph; - use pecos::quantum::{Attribute, DagCircuit, TickCircuit}; + use pecos::quantum::{Attribute, DagCircuit, TickCircuit, TickGateError}; // Fluent builder API let mut circuit = DagCircuit::new(); @@ -76,8 +76,9 @@ circuit.h(&[0]).meta("error_rate", Attribute::Float(0.001)); // Multiple metadata entries circuit.cx(&[(0, 1)]).meta("duration_ns", Attribute::Int(50)); -// Measurements break the chain but still support metadata -circuit.mz(&[0]).meta("basis", Attribute::String("Z".into())); +// Measurements return refs (not &mut Self), so chain separately +circuit.mz(&[0]); +circuit.meta("basis", Attribute::String("Z".into())); } @@ -162,6 +163,7 @@ circuit.tick().mz(&[0, 1]); println!("Number of ticks: {}", circuit.num_ticks()); println!("Total gates: {}", circuit.gate_count()); +println!("Gate batches: {}", circuit.gate_batch_count()); } @@ -181,7 +183,9 @@ tick.h(&[0]); // tick.cx(&[(0, 1)]); // Use try_add_gate for fallible operations -if let Err(e) = tick.try_add_gate(Gate::cx(&[(0, 1)])) { +if let Err(pecos::quantum::TickGateError::QubitConflict(e)) = + tick.try_add_gate(Gate::cx(&[(0, 1)])) +{ println!("Conflict on qubits: {:?}", e.conflicting_qubits); } diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_fault_catalog.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_fault_catalog.rs new file mode 100644 index 000000000..385fdc037 --- /dev/null +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_fault_catalog.rs @@ -0,0 +1,114 @@ +//! Auto-generated Rust tests from user-guide/fault-catalog.md +//! DO NOT EDIT - Generated by scripts/docs/generate_doc_tests.py +#![allow(unused_imports, unused_variables, unused_mut, unused_assignments, dead_code, non_snake_case)] + + + +#[test] +fn test_user_guide_fault_catalog_rust_1() -> Result<(), Box> { + use pecos_quantum::{Attribute, TickCircuit}; + use pecos_qec::fault_tolerance::fault_sampler::{ +FaultCatalog, StochasticNoiseParams, +}; + let mut circuit = TickCircuit::new(); + circuit.tick().h(&[0]); + circuit.tick().mz(&[0]); + + circuit.set_meta("num_measurements", Attribute::String("1".into())); + circuit.set_meta( + "detectors", + Attribute::String(r#"[{"records":[-1]}]"#.into()), + ); + circuit.set_meta( + "observables", + Attribute::String(r#"[{"records":[-1]}]"#.into()), + ); + + // Structural catalog (no noise): + let mut catalog = FaultCatalog::from_circuit(&circuit).unwrap(); + + // Parameterize: + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }; + catalog.with_noise(&noise); + + // Or one-shot convenience: + // let catalog = build_fault_catalog(&circuit, &noise).unwrap(); + + for loc in &catalog.locations { + println!( + "tick={} gate={:?} channel={:?} p={} k={}", + loc.tick, + loc.gate_type, + loc.channel, + loc.channel_probability, + loc.num_alternatives + ); + + for fault in &loc.faults { + println!( + " {:?} dets={:?} obs={:?} tracked={:?} p_alt={}", + fault.kind, + fault.affected_detectors, + fault.affected_observables, + fault.affected_tracked_paulis, + fault.absolute_probability + ); + } + } + Ok(()) +} + + + +#[test] +fn test_user_guide_fault_catalog_rust_2() -> Result<(), Box> { + use pecos_quantum::{Attribute, TickCircuit}; + use pecos_qec::fault_tolerance::fault_sampler::{ +FaultCatalog, StochasticNoiseParams, +}; + let mut circuit = TickCircuit::new(); + circuit.tick().h(&[0]); + circuit.tick().mz(&[0]); + + circuit.set_meta("num_measurements", Attribute::String("1".into())); + circuit.set_meta( + "detectors", + Attribute::String(r#"[{"records":[-1]}]"#.into()), + ); + circuit.set_meta( + "observables", + Attribute::String(r#"[{"records":[-1]}]"#.into()), + ); + + // Structural catalog (no noise): + let mut catalog = FaultCatalog::from_circuit(&circuit).unwrap(); + + // Parameterize: + let noise = StochasticNoiseParams { + p1: 0.03, + p2: 0.0, + p_meas: 0.01, + p_prep: 0.0, + }; + catalog.with_noise(&noise); + + // Or one-shot convenience: + // let catalog = build_fault_catalog(&circuit, &noise).unwrap(); + + for event in catalog.fault_configurations(2) { + println!( + "locations={:?} alternatives={:?} dets={:?} obs={:?} p={}", + event.location_indices, + event.alternative_indices, + event.affected_detectors, + event.affected_observables, + event.configuration_probability + ); + } + Ok(()) +} diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_fault_tolerance.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_fault_tolerance.rs index 442214c2f..2de380d96 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_fault_tolerance.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_fault_tolerance.rs @@ -7,7 +7,8 @@ #[test] fn test_user_guide_fault_tolerance_rust_1() { - use pecos_core::{PauliString, QuarterPhase, Xs, Zs}; + use pecos_core::{PauliString, QuarterPhase}; + use pecos_core::pauli::{Xs, Zs}; use pecos_qec::{ErrorClass, StabilizerCodeSpec, StabilizerFlipChecker}; let code = StabilizerCodeSpec::builder(3) .check(Zs([0, 1])) @@ -24,8 +25,7 @@ fn test_user_guide_fault_tolerance_rust_1() { #[test] fn test_user_guide_fault_tolerance_rust_2() { - use pecos_core::pauli::constructors::*; - use pecos_core::{Xs, Zs}; + use pecos_core::pauli::*; use pecos_qec::{ErrorClass, StabilizerCodeSpec, StabilizerFlipChecker}; let code = StabilizerCodeSpec::builder(3) .check(Zs([0, 1])) @@ -51,8 +51,7 @@ fn test_user_guide_fault_tolerance_rust_2() { #[test] fn test_user_guide_fault_tolerance_rust_3() -> Result<(), Box> { - use pecos_core::pauli::constructors::*; - use pecos_core::{Xs, Zs}; + use pecos_core::pauli::*; use pecos_qec::{StabilizerCodeSpec, StabilizerFlipChecker}; let code = StabilizerCodeSpec::builder(3) .check(Zs([0, 1])).check(Zs([1, 2])) @@ -72,7 +71,7 @@ fn test_user_guide_fault_tolerance_rust_3() -> Result<(), Box Result<(), Box> { - use pecos_core::{Xs, Zs}; + use pecos_core::pauli::{Xs, Zs}; use pecos_qec::{StabilizerCodeSpec, StabilizerFlipChecker}; let code = StabilizerCodeSpec::builder(3) .check(Zs([0, 1])).check(Zs([1, 2])) @@ -98,7 +97,7 @@ fn test_user_guide_fault_tolerance_rust_4() -> Result<(), Box Result<(), Box Result<(), Box> { - use pecos_core::{Xs, Zs}; + use pecos_core::pauli::{Xs, Zs}; use pecos_qec::{DemBuilder, DistanceSearchConfig, StabilizerCodeSpec, calculate_distance}; use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; use pecos_quantum::DagCircuit; @@ -259,7 +258,7 @@ let result = calculate_distance(&code, &DistanceSearchConfig::with_max_weight(5) #[test] fn test_user_guide_fault_tolerance_rust_10() -> Result<(), Box> { - use pecos_core::{Xs, Zs}; + use pecos_core::pauli::{Xs, Zs}; use pecos_qec::{DemBuilder, DistanceSearchConfig, StabilizerCodeSpec, find_min_weight_logicals_with_info}; use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; use pecos_quantum::DagCircuit; @@ -302,7 +301,7 @@ for op in &logicals { #[test] fn test_user_guide_fault_tolerance_rust_11() -> Result<(), Box> { - use pecos_core::pauli::constructors::Zs; + use pecos_core::pauli::Zs; use pecos_qec::{DemBuilder, discover_logical_operators}; use pecos_qec::fault_tolerance::propagator::DagFaultAnalyzer; use pecos_quantum::DagCircuit; diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_gate_angle_types.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_gate_angle_types.rs index 0014195f7..2d173e251 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_gate_angle_types.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_gate_angle_types.rs @@ -92,7 +92,7 @@ fn test_user_guide_gate_angle_types_rust_5() { #[test] fn test_user_guide_gate_angle_types_rust_6() { use pecos_core::{phase, phase_turn}; - use pecos_core::unitary_rep::X; + use pecos_core::unitary::X; let op = phase!(pi / 4) * X(0); // e^{i*pi/4} * X let op = phase_turn!(1 / 8) * X(0); // same thing } diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_pecos_concepts.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_pecos_concepts.rs new file mode 100644 index 000000000..1b1038856 --- /dev/null +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_pecos_concepts.rs @@ -0,0 +1,25 @@ +//! Auto-generated Rust tests from user-guide/pecos-concepts.md +//! DO NOT EDIT - Generated by scripts/docs/generate_doc_tests.py +#![allow(unused_imports, unused_variables, unused_mut, unused_assignments, dead_code, non_snake_case)] + + + +#[test] +fn test_user_guide_pecos_concepts_rust_1() { + use pecos_core::pauli::*; + use pecos_core::PauliOperator; + let logical_x = X(0) & X(1) & X(2); + let z_probe = Z(3); + + assert_eq!(logical_x.weight(), 3); + assert!(logical_x.commutes_with(&z_probe)); +} + + + +#[test] +fn test_user_guide_pecos_concepts_rust_2() { + use pecos_core::{PauliOperator, PauliString}; + let stabilizer: PauliString = "Z0 Z1 Z4 Z5".parse().unwrap(); + assert_eq!(stabilizer.weight(), 4); +} diff --git a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_quantum_operator_algebra.rs b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_quantum_operator_algebra.rs index 66fd6aa5b..ba16ea200 100644 --- a/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_quantum_operator_algebra.rs +++ b/python/quantum-pecos/tests/docs/rust_crate/tests/user_guide_quantum_operator_algebra.rs @@ -7,7 +7,7 @@ #[test] fn test_user_guide_quantum_operator_algebra_rust_1() { - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; use pecos_core::PauliOperator; let p = X(0); // X on qubit 0 let q = Z(3); // Z on qubit 3 @@ -33,7 +33,7 @@ fn test_user_guide_quantum_operator_algebra_rust_1() { #[test] fn test_user_guide_quantum_operator_algebra_rust_2() { - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; use pecos_core::pauli::algebra::i; let imag = i * X(0); // iX let neg_imag = -i * Y(1); // -iY @@ -43,7 +43,7 @@ fn test_user_guide_quantum_operator_algebra_rust_2() { #[test] fn test_user_guide_quantum_operator_algebra_rust_3() { - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; use pecos_core::{PauliOperator, QuarterPhase}; let a = X(0) & Z(1); let b = Z(0) & X(1); @@ -70,9 +70,16 @@ fn test_user_guide_quantum_operator_algebra_rust_3() { #[test] fn test_user_guide_quantum_operator_algebra_rust_4() { use pecos_core::PauliString; - let p: PauliString = "XZI".parse().unwrap(); // X(0) & Z(1) - let q: PauliString = "+XZZXI".parse().unwrap(); // with explicit phase - let r: PauliString = "-iYX".parse().unwrap(); // -i * Y(0) * X(1) + let sparse: PauliString = "X0 Z3".parse().unwrap(); + let dense: PauliString = "XIIZ".parse().unwrap(); + let explicit_sparse = PauliString::from_sparse_str("X0 Z3").unwrap(); + let explicit_dense = PauliString::from_dense_str("XIIZ").unwrap(); + + assert_eq!(sparse, dense); + assert_eq!(sparse, explicit_sparse); + assert_eq!(dense, explicit_dense); + assert_eq!(sparse.to_sparse_str(), "+X0 Z3"); + assert_eq!(sparse.to_dense_str(None), "+XIIZ"); } @@ -80,7 +87,7 @@ fn test_user_guide_quantum_operator_algebra_rust_4() { #[test] fn test_user_guide_quantum_operator_algebra_rust_5() { - use pecos_core::pauli::constructors::*; + use pecos_core::pauli::*; use pecos_core::clifford_rep::CliffordRep; let h = CliffordRep::h(0); assert_eq!(*h.x_image(0), Z(0)); // H X H^dag = Z @@ -100,7 +107,7 @@ fn test_user_guide_quantum_operator_algebra_rust_5() { #[test] fn test_user_guide_quantum_operator_algebra_rust_6() { - use pecos_core::unitary_rep::*; + use pecos_core::unitary::*; use pecos_core::Angle64; let circuit = T(1) * CX(0, 1) * H(0); // apply H, then CX, then T @@ -123,7 +130,7 @@ fn test_user_guide_quantum_operator_algebra_rust_6() { } -// Measurement and preparation +// Measurement, preparation, and reset #[test] fn test_user_guide_quantum_operator_algebra_rust_7() { @@ -131,25 +138,33 @@ fn test_user_guide_quantum_operator_algebra_rust_7() { let mz = MZ(0); // Z-basis measurement on qubit 0 let mx = MX(1); // X-basis measurement on qubit 1 let pz = PZ(0); // Prepare |0> on qubit 0 + let reset = Reset(0); // Reset to |0> + assert!(mz.is_gate()); +} + + +// Noise channels - // Noise channels +#[test] +fn test_user_guide_quantum_operator_algebra_rust_8() { + use pecos_core::op::*; let depol = Depolarizing(0.01, 0); // 1% depolarizing on qubit 0 let deph = Dephasing(0.02, 1); // 2% dephasing on qubit 1 let amp_damp = AmplitudeDamping(0.05, 0); // T1 decay let phase_damp = PhaseDamping(0.03, 0); // T2 dephasing let erasure = Erasure(0.01, 0); // Erasure channel - let reset = Reset(0); // Reset to |0> let leak = Leakage(0.001, 0); // Leakage to non-computational state // Custom Pauli channel let pauli_ch = PauliChannel(0.01, 0.01, 0.01, 0); // px, py, pz + assert!(depol.is_channel()); } // Pauli & Pauli stays Pauli #[test] -fn test_user_guide_quantum_operator_algebra_rust_8() { +fn test_user_guide_quantum_operator_algebra_rust_9() { use pecos_core::op::*; let p = X(0) & Y(3); assert!(p.is_pauli()); @@ -162,15 +177,19 @@ fn test_user_guide_quantum_operator_algebra_rust_8() { let u = X(0) & H(3) & T(5); assert!(u.is_unitary()); - // Adding a measurement promotes to Channel - let ch = H(0) & MZ(1); + // Adding a measurement promotes to Gate + let g = H(0) & MZ(1); + assert!(g.is_gate()); + + // Adding noise promotes to Channel + let ch = g & Depolarizing(0.01, 2); assert!(ch.is_channel()); } #[test] -fn test_user_guide_quantum_operator_algebra_rust_9() { +fn test_user_guide_quantum_operator_algebra_rust_10() { use pecos_core::op::*; let p = X(0) & Z(1); let ps = p.as_pauli().unwrap(); // borrow the inner PauliString @@ -188,7 +207,7 @@ fn test_user_guide_quantum_operator_algebra_rust_9() { #[test] -fn test_user_guide_quantum_operator_algebra_rust_10() { +fn test_user_guide_quantum_operator_algebra_rust_11() { use pecos_core::op::*; let circuit = T(1) * CX(0, 1) * H(0); let inverse = circuit.dg(); // works for Pauli, Clifford, Unitary @@ -201,18 +220,18 @@ fn test_user_guide_quantum_operator_algebra_rust_10() { #[test] -fn test_user_guide_quantum_operator_algebra_rust_11() { +fn test_user_guide_quantum_operator_algebra_rust_12() { use pecos_core::op::*; let circuit = CX(0, 3) & H(5); - assert_eq!(circuit.num_qubits(), 6); // spans qubits 0..5 - assert_eq!(circuit.qubits(), vec![0, 1, 2, 3, 4, 5]); // full range + assert_eq!(circuit.num_qubits(), 6); // matrix span is qubits 0..5 + assert_eq!(circuit.qubits(), vec![0, 3, 5]); // actual support } #[test] -fn test_user_guide_quantum_operator_algebra_rust_12() { - use pecos_core::pauli::constructors::*; +fn test_user_guide_quantum_operator_algebra_rust_13() { + use pecos_core::pauli::*; use pecos_quantum::PauliSequence; let seq = PauliSequence::new(vec![ Zs([0, 1]), @@ -238,8 +257,8 @@ fn test_user_guide_quantum_operator_algebra_rust_12() { #[test] -fn test_user_guide_quantum_operator_algebra_rust_13() { - use pecos_core::pauli::constructors::*; +fn test_user_guide_quantum_operator_algebra_rust_14() { + use pecos_core::pauli::*; use pecos_quantum::PauliSet; let mut set = PauliSet::new(); set.insert(&X(0)); @@ -259,8 +278,8 @@ fn test_user_guide_quantum_operator_algebra_rust_13() { // Generators with imaginary phase #[test] -fn test_user_guide_quantum_operator_algebra_rust_14() { - use pecos_core::pauli::constructors::*; +fn test_user_guide_quantum_operator_algebra_rust_15() { + use pecos_core::pauli::*; use pecos_core::pauli::algebra::i; use pecos_quantum::PauliGroup; let group = PauliGroup::new(vec![ @@ -280,8 +299,8 @@ fn test_user_guide_quantum_operator_algebra_rust_14() { // Repetition code stabilizers #[test] -fn test_user_guide_quantum_operator_algebra_rust_15() -> Result<(), Box> { - use pecos_core::pauli::constructors::*; +fn test_user_guide_quantum_operator_algebra_rust_16() -> Result<(), Box> { + use pecos_core::pauli::*; use pecos_core::clifford_rep::CliffordRep; use pecos_quantum::PauliStabilizerGroup; let stab = PauliStabilizerGroup::new(vec![ @@ -309,8 +328,8 @@ fn test_user_guide_quantum_operator_algebra_rust_15() -> Result<(), Box None: """Test that CudaStateVec can be imported.""" from pecos.simulators import CudaStateVec @@ -172,6 +192,11 @@ def test_run_gate_interface(self) -> None: class TestCudaStabilizer: """Tests for CudaStabilizer (Rust cuQuantum stabilizer simulator).""" + pytestmark = pytest.mark.skipif( + not CUSTABILIZER_USABLE, + reason="CudaStabilizer runtime is not usable on this machine", + ) + def test_import(self) -> None: """Test that CudaStabilizer can be imported.""" from pecos.simulators import CudaStabilizer @@ -275,6 +300,11 @@ def test_surface_code_syndrome(self) -> None: class TestCuTensorNet: """Tests for CuTensorNet handle.""" + pytestmark = pytest.mark.skipif( + not CUTENSORNET_USABLE, + reason="CuTensorNet runtime is not usable on this machine", + ) + def test_import(self) -> None: """Test that CuTensorNet can be imported from pecos_rslib_cuda.""" from pecos_rslib_cuda import CuTensorNet @@ -300,6 +330,11 @@ def test_version(self) -> None: class TestCuDensityMat: """Tests for CuDensityMat density matrix simulator.""" + pytestmark = pytest.mark.skipif( + not CUDENSITYMAT_USABLE, + reason="CuDensityMat runtime is not usable on this machine", + ) + def test_import(self) -> None: """Test that CuDensityMat can be imported from pecos_rslib_cuda.""" from pecos_rslib_cuda import CuDensityMat @@ -337,6 +372,10 @@ def test_small_qubit_count(self) -> None: class TestQuantumSimulatorBackend: """Tests for QuantumSimulator with CUDA backends.""" + @pytest.mark.skipif( + not CUSTATEVEC_USABLE, + reason="CudaStateVec runtime is not usable on this machine", + ) def test_cuda_statevec_backend(self) -> None: """Test QuantumSimulator with CudaStateVec backend.""" from pecos.simulators.quantum_simulator import QuantumSimulator @@ -346,6 +385,10 @@ def test_cuda_statevec_backend(self) -> None: assert sim.num_qubits == 4 + @pytest.mark.skipif( + not CUSTABILIZER_USABLE, + reason="CudaStabilizer runtime is not usable on this machine", + ) def test_cuda_stabilizer_backend(self) -> None: """Test QuantumSimulator with CudaStabilizer backend.""" from pecos.simulators.quantum_simulator import QuantumSimulator diff --git a/python/quantum-pecos/tests/pecos/integration/state_sim_tests/test_statevec.py b/python/quantum-pecos/tests/pecos/integration/state_sim_tests/test_statevec.py index e46385da5..fe2cb5641 100644 --- a/python/quantum-pecos/tests/pecos/integration/state_sim_tests/test_statevec.py +++ b/python/quantum-pecos/tests/pecos/integration/state_sim_tests/test_statevec.py @@ -430,7 +430,7 @@ def test_hybrid_engine_noisy(simulator: str) -> None: "p1": 2e-1, "p2": 2e-1, "p_meas": 2e-1, - "p_init": 1e-1, + "p_prep": 1e-1, "p1_error_model": { "X": 0.25, "Y": 0.25, diff --git a/python/quantum-pecos/tests/pecos/integration/test_backend_seed_determinism.py b/python/quantum-pecos/tests/pecos/integration/test_backend_seed_determinism.py index 0c1a2f5ec..7cb51baf2 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_backend_seed_determinism.py +++ b/python/quantum-pecos/tests/pecos/integration/test_backend_seed_determinism.py @@ -108,7 +108,7 @@ "p1": 2e-1, "p2": 2e-1, "p_meas": 2e-1, - "p_init": 1e-1, + "p_prep": 1e-1, "p1_error_model": { "X": 0.25, "Y": 0.25, diff --git a/python/quantum-pecos/tests/pecos/integration/test_hybrid_engine_old_error_model.py b/python/quantum-pecos/tests/pecos/integration/test_hybrid_engine_old_error_model.py index 942f5afc7..aa91761fb 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_hybrid_engine_old_error_model.py +++ b/python/quantum-pecos/tests/pecos/integration/test_hybrid_engine_old_error_model.py @@ -18,7 +18,7 @@ def test_simple_conditional() -> None: error_params = { "p1": 0.01, "p2": 0.01, - "p_init": 0.01, + "p_prep": 0.01, "p_meas": 0.01, "p2_mem": 0.01, } diff --git a/python/quantum-pecos/tests/pecos/integration/test_phir.py b/python/quantum-pecos/tests/pecos/integration/test_phir.py index a3e21fb61..467872450 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_phir.py +++ b/python/quantum-pecos/tests/pecos/integration/test_phir.py @@ -16,9 +16,6 @@ import pytest from pecos import WasmForeignObject -from pecos.classical_interpreters.phir_classical_interpreter import ( - PhirClassicalInterpreter, -) from pecos.engines.hybrid_engine import HybridEngine from pecos.noise.generic_error_model import GenericErrorModel from phir.model import PHIRModel @@ -60,7 +57,7 @@ def test_spec_example_noisy_wasmtime() -> None: "p1": 2e-1, "p2": 2e-1, "p_meas": 2e-1, - "p_init": 1e-1, + "p_prep": 1e-1, "p1_error_model": { "X": 0.25, "Y": 0.25, @@ -95,7 +92,7 @@ def test_example1_noisy_wasmtime() -> None: "p1": 2e-1, "p2": 2e-1, "p_meas": 2e-1, - "p_init": 1e-1, + "p_prep": 1e-1, "p1_error_model": { "X": 0.25, "Y": 0.25, @@ -129,7 +126,7 @@ def test_example1_no_wasm_noisy() -> None: "p1": 2e-1, "p2": 2e-1, "p_meas": 2e-1, - "p_init": 1e-1, + "p_prep": 1e-1, "p1_error_model": { "X": 0.25, "Y": 0.25, @@ -211,11 +208,7 @@ def test_bell_qparallel_cliff() -> None: Tests that a program creating and measuring a Bell state using qparallel blocks returns expected results with Clifford circuits and stabilizer simulator. """ - # Create an interpreter with validation disabled for testing Result instruction - interp = PhirClassicalInterpreter() - interp.phir_validate = False - - results = HybridEngine(qsim="stabilizer", cinterp=interp).run( + results = HybridEngine(qsim="stabilizer").run( program=json.load( Path.open(this_dir / "phir" / "bell_qparallel_cliff.phir.json"), ), @@ -235,10 +228,7 @@ def test_bell_qparallel_cliff_barrier() -> None: Tests that a program creating and measuring a Bell state using qparallel blocks and barriers returns expected results with Clifford circuits and stabilizer simulator. """ - interp = PhirClassicalInterpreter() - interp.phir_validate = False - - results = HybridEngine(qsim="stabilizer", cinterp=interp).run( + results = HybridEngine(qsim="stabilizer").run( program=json.load( Path.open(this_dir / "phir" / "bell_qparallel_cliff_barrier.phir.json"), ), @@ -258,10 +248,7 @@ def test_bell_qparallel_cliff_ifbarrier() -> None: Tests that a program creating and measuring a Bell state using qparallel blocks and conditional barriers returns expected results with Clifford circuits and stabilizer simulator. """ - interp = PhirClassicalInterpreter() - interp.phir_validate = False - - results = HybridEngine(qsim="stabilizer", cinterp=interp).run( + results = HybridEngine(qsim="stabilizer").run( program=json.load( Path.open(this_dir / "phir" / "bell_qparallel_cliff_ifbarrier.phir.json"), ), diff --git a/python/quantum-pecos/tests/pecos/integration/test_phir_dep.py b/python/quantum-pecos/tests/pecos/integration/test_phir_dep.py index 5ed34b167..f6412b1ed 100644 --- a/python/quantum-pecos/tests/pecos/integration/test_phir_dep.py +++ b/python/quantum-pecos/tests/pecos/integration/test_phir_dep.py @@ -14,6 +14,7 @@ import json from pathlib import Path +import pytest from pecos.typing import PhirModel this_dir = Path(__file__).parent @@ -25,3 +26,97 @@ def test_spec_example() -> None: data = json.load(Path.open(this_dir / "phir/spec_example.phir.json")) PhirModel.model_validate(data) + + +def test_pecos_result_cop_top_level() -> None: + """PECOS Result cop validates at the top level of a PHIR program. + + The upstream ``phir.model.PHIRModel`` rejects Result because it is a + PECOS extension, not part of the spec. ``pecos.typing.PhirModel`` + subclasses the upstream model to add Result support. + """ + data = { + "format": "PHIR/JSON", + "version": "0.1.0", + "ops": [ + {"data": "cvar_define", "data_type": "u32", "variable": "m", "size": 1}, + {"data": "cvar_define", "data_type": "u32", "variable": "c", "size": 1}, + {"cop": "Result", "args": ["m"], "returns": ["c"]}, + ], + } + + PhirModel.model_validate(data) + + +def test_pecos_result_cop_inside_seqblock() -> None: + """Result cop validates when nested inside a SeqBlock.""" + data = { + "format": "PHIR/JSON", + "version": "0.1.0", + "ops": [ + {"data": "cvar_define", "data_type": "u32", "variable": "m", "size": 1}, + {"data": "cvar_define", "data_type": "u32", "variable": "c", "size": 1}, + { + "block": "sequence", + "ops": [{"cop": "Result", "args": ["m"], "returns": ["c"]}], + }, + ], + } + + PhirModel.model_validate(data) + + +@pytest.mark.parametrize( + ("dtype", "size"), + [ + ("i8", 8), + ("u8", 8), + ("i16", 16), + ("u16", 16), + ("i32", 32), + ("u32", 32), + ("i64", 64), + ("u64", 64), + ], +) +def test_pecos_cvar_define_small_dtypes(dtype: str, size: int) -> None: + """PECOS extends CVarDefine to support 8-bit and 16-bit integer dtypes. + + The upstream ``phir.model.CVarDefine`` only permits ``i32``, ``i64``, + ``u32``, ``u64``. PECOS programs use 8/16-bit dtypes too. + """ + data = { + "format": "PHIR/JSON", + "version": "0.1.0", + "ops": [{"data": "cvar_define", "data_type": dtype, "variable": "v", "size": size}], + } + + PhirModel.model_validate(data) + + +def test_cvar_define_size_exceeding_dtype_rejected() -> None: + """Extension remains strict: size must fit in the declared dtype.""" + from pydantic import ValidationError + + for dtype, bad_size in [("i8", 16), ("u8", 9), ("i16", 32), ("u16", 17)]: + data = { + "format": "PHIR/JSON", + "version": "0.1.0", + "ops": [{"data": "cvar_define", "data_type": dtype, "variable": "v", "size": bad_size}], + } + with pytest.raises(ValidationError): + PhirModel.model_validate(data) + + +def test_malformed_result_cop_still_rejected() -> None: + """Extension remains strict: Result cop missing required fields is rejected.""" + from pydantic import ValidationError + + data = { + "format": "PHIR/JSON", + "version": "0.1.0", + "ops": [{"cop": "Result"}], # missing args and returns + } + + with pytest.raises(ValidationError): + PhirModel.model_validate(data) diff --git a/python/quantum-pecos/tests/pecos/test_pauli_string_bindings.py b/python/quantum-pecos/tests/pecos/test_pauli_string_bindings.py new file mode 100644 index 000000000..c0ba3beb5 --- /dev/null +++ b/python/quantum-pecos/tests/pecos/test_pauli_string_bindings.py @@ -0,0 +1,153 @@ +# Copyright 2026 The PECOS Developers +# +# Licensed under the Apache License, Version 2.0 + +import pytest +from pecos_rslib import H, Pauli, PauliString, X, Y, Z + + +def test_pauli_string_from_str_accepts_dense_and_sparse_formats() -> None: + expected = X(0) & X(1) & Z(3) + + assert PauliString.from_str("XXIZ") == expected + assert PauliString.from_str("X0 X1 Z3") == expected + assert PauliString.from_str("X 0 X 1 Z 3") == expected + + +def test_pauli_string_common_representations_are_interchangeable_and_hash_equal() -> None: + from_constructors = X(0) & Y(2) & Z(5) + from_class_constructors = PauliString.X(0) & PauliString.Y(2) & PauliString.Z(5) + from_sparse = PauliString.from_str("X0 Y2 Z5") + from_explicit_sparse = PauliString.from_sparse_str("X0 Y2 Z5") + from_dense = PauliString.from_str("XIYIIZ") + from_explicit_dense = PauliString.from_dense_str("XIYIIZ") + from_tuples = PauliString([(Pauli.Z, 5), (Pauli.X, 0), (Pauli.Y, 2)]) + + forms = [ + from_constructors, + from_class_constructors, + from_sparse, + from_explicit_sparse, + from_dense, + from_explicit_dense, + from_tuples, + ] + assert all(form == from_constructors for form in forms) + assert len({hash(form) for form in forms}) == 1 + assert {form: idx for idx, form in enumerate(forms)} == {from_constructors: len(forms) - 1} + + +def test_pauli_string_tensor_result_is_pauli_string() -> None: + tensor = X(0) & Y(3) + + assert isinstance(tensor, PauliString) + assert tensor.get_paulis() == [(Pauli.X, 0), (Pauli.Y, 3)] + + +def test_pauli_string_tensor_equality_hash_and_text_forms_match() -> None: + tensor = X(0) & Y(3) + same_sparse = PauliString.from_sparse_str("X0 Y3") + same_dense = PauliString.from_dense_str("XIIY") + same_from_tuples = PauliString([(Pauli.Y, 3), (Pauli.X, 0)]) + + assert tensor == same_sparse == same_dense == same_from_tuples + assert len({tensor, same_sparse, same_dense, same_from_tuples}) == 1 + assert tensor.to_sparse_str() == "+X0 Y3" + assert tensor.to_dense_str() == "+XIIY" + + +def test_pauli_string_explicit_from_dense_and_sparse_formats() -> None: + expected = X(0) & Z(3) + + assert PauliString.from_dense_str("XIIZ") == expected + assert PauliString.from_sparse_str("X0 Z3") == expected + + +def test_pauli_string_from_str_sparse_keeps_phase_and_high_qubits() -> None: + pauli = PauliString.from_str("-i X2 Z10000") + + assert pauli.get_phase() == 3 + assert pauli.get_paulis() == [(Pauli.X, 2), (Pauli.Z, 10000)] + assert pauli.weight() == 2 + + +def test_pauli_string_dense_and_sparse_round_trips() -> None: + pauli = PauliString.from_sparse_str("-i X2 Z4") + + assert pauli.to_sparse_str() == "-iX2 Z4" + assert pauli.to_dense_str() == "-iIIXIZ" + assert pauli.to_dense_str(num_qubits=7) == "-iIIXIZII" + assert PauliString.from_sparse_str(pauli.to_sparse_str()) == pauli + assert PauliString.from_dense_str(pauli.to_dense_str()) == pauli + + +def test_pauli_string_tuple_constructor_canonicalizes_for_hashing() -> None: + sorted_pauli = PauliString([(Pauli.X, 0), (Pauli.Y, 3)]) + unsorted_pauli = PauliString([(Pauli.Y, 3), (Pauli.X, 0)]) + constructed = X(0) & PauliString.Y(3) + + assert sorted_pauli == unsorted_pauli == constructed + assert hash(sorted_pauli) == hash(unsorted_pauli) == hash(constructed) + assert {sorted_pauli: "first", unsorted_pauli: "second"} == {constructed: "second"} + + +def test_pauli_string_tuple_constructor_rejects_duplicate_qubits() -> None: + with pytest.raises(ValueError, match="multiple non-identity"): + PauliString([(Pauli.X, 0), (Pauli.Z, 0)]) + + assert PauliString([(Pauli.I, 0), (Pauli.X, 0)]) == X(0) + + +def test_pauli_string_tensor_rejects_overlapping_qubits() -> None: + with pytest.raises(ValueError, match=r"overlapping qubits: \[0\]"): + _ = X(0) & Y(0) + + with pytest.raises(ValueError, match="tensor product requires disjoint Pauli support"): + _ = X(0) & Z(0) + + with pytest.raises(ValueError, match=r"overlapping qubits: \[2\]"): + _ = (X(0) & Y(2)) & Z(2) + + +def test_pauli_string_tensor_rejects_non_pauli_operands_explicitly() -> None: + with pytest.raises(TypeError): + _ = X(0) & H(1) + + with pytest.raises(TypeError): + _ = H(0) & H(1) + + +def test_pauli_string_composition_allows_same_qubit() -> None: + composed = X(0) * Z(0) + + assert composed.get_paulis() == [(Pauli.Y, 0)] + assert composed.get_phase() == 3 + + +def test_pauli_string_tensor_result_is_hashable() -> None: + tensor = X(0) & Y(3) + + assert {tensor: "xy"}[PauliString.from_sparse_str("X0 Y3")] == "xy" + + +def test_pauli_string_tensor_preserves_phase_through_string_roundtrip() -> None: + tensor = -X(0) & Z(1) + + assert tensor.get_phase() == 2 + assert tensor.to_sparse_str() == "-X0 Z1" + assert tensor.to_dense_str() == "-XZ" + assert PauliString.from_sparse_str(tensor.to_sparse_str()) == tensor + assert PauliString.from_dense_str(tensor.to_dense_str()) == tensor + + +def test_quantum_namespace_exports_pauli_constructors() -> None: + import pecos.quantum as quantum + from pecos.quantum import pauli_string + + expected = X(0) & Z(3) + + assert quantum.X(0) & quantum.Z(3) == expected + assert pauli_string("X0 Z3") == expected + assert pauli_string("XIIZ") == expected + assert pauli_string(((quantum.Pauli.X, 0), (quantum.Pauli.Z, 3))) == expected + assert pauli_string({0: quantum.Pauli.X, 3: quantum.Pauli.Z}) == expected diff --git a/python/quantum-pecos/tests/pecos/test_quantum_info_bindings.py b/python/quantum-pecos/tests/pecos/test_quantum_info_bindings.py new file mode 100644 index 000000000..1ef48572b --- /dev/null +++ b/python/quantum-pecos/tests/pecos/test_quantum_info_bindings.py @@ -0,0 +1,318 @@ +from __future__ import annotations + +import pytest +from pecos.quantum import X, Z +from pecos.quantum_info import ( + ChiMatrix, + ChoiMatrix, + PauliChannel, + ProcessTomographyDesign, + Ptm, + Stinespring, + SuperOp, + average_gate_fidelity, + entropy, + gate_error, + hellinger_distance, + hellinger_fidelity, + logarithmic_negativity, + matrix_unit_basis, + negativity, + partial_trace_qubits, + partial_trace_subsystems, + pauli_channel_diamond_distance, + pauli_channel_diamond_norm, + process_fidelity, + purity, + random_density_matrix, + random_quantum_channel, + schmidt_decomposition, + shannon_entropy, + state_fidelity, + state_fidelity_with_density_matrix, +) +from pecos_rslib import PauliString + + +def assert_close(actual: float, expected: float, tol: float = 1e-12) -> None: + assert abs(actual - expected) < tol + + +def assert_matrix_close(actual: list[list[complex]], expected: list[list[complex]]) -> None: + assert len(actual) == len(expected) + for actual_row, expected_row in zip(actual, expected, strict=True): + assert len(actual_row) == len(expected_row) + for actual_value, expected_value in zip(actual_row, expected_row, strict=True): + assert abs(actual_value - expected_value) < 1e-12 + + +def test_pauli_channel_exposes_probabilities_and_ptm() -> None: + channel = PauliChannel.one_qubit(0.1, 0.2, 0.0) + + assert channel.num_qubits() == 1 + assert_close(channel.total_error_rate(), 0.3) + assert channel.probabilities() == {"I": 0.7, "X": 0.1, "Y": 0.2} + + ptm = channel.to_ptm() + assert ptm.num_qubits() == 1 + assert_close(ptm.entry(0, 0), 1.0) + + other = PauliChannel.one_qubit(0.0, 0.2, 0.3) + assert_close(pauli_channel_diamond_norm(channel, other), 0.6) + assert_close(pauli_channel_diamond_distance(channel, other), 0.3) + + +def test_pauli_channel_accepts_pauli_string_probability_keys() -> None: + channel = PauliChannel.from_probabilities( + 2, + { + PauliString.I(): 0.97, + X(0): 0.01, + Z(1): 0.02, + }, + ) + + assert channel.probabilities() == {"II": 0.97, "IX": 0.01, "ZI": 0.02} + assert_close(channel.total_error_rate(), 0.03) + + from_sequence = PauliChannel.from_probabilities( + 2, + [ + (PauliString.I(), 0.97), + (X(0), 0.01), + (Z(1), 0.02), + ], + ) + assert from_sequence.probabilities() == channel.probabilities() + + +def test_pauli_channel_rejects_ambiguous_pauli_string_keys() -> None: + with pytest.raises(ValueError, match="unphased"): + PauliChannel.from_probabilities(1, {-X(0): 1.0}) + + with pytest.raises(ValueError, match="outside num_qubits"): + PauliChannel.from_probabilities(1, {Z(1): 1.0}) + + with pytest.raises(ValueError, match="duplicate"): + PauliChannel.from_probabilities(1, [("X", 0.5), (X(0), 0.5)]) + + +def test_choi_and_kraus_wrappers_round_trip_identity_channel() -> None: + identity = Ptm.identity(1) + choi = identity.to_choi() + + assert isinstance(choi, ChoiMatrix) + assert choi.is_completely_positive() + assert choi.is_trace_preserving() + assert choi.is_cptp() + assert choi.is_unital() + assert_matrix_close( + choi.partial_trace_output(), + [[1.0 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 1.0 + 0.0j]], + ) + + kraus = choi.to_kraus() + assert kraus.num_qubits() == 1 + assert kraus.is_trace_preserving() + assert_close(process_fidelity(kraus.to_ptm(), identity), 1.0) + assert_close(average_gate_fidelity(kraus.to_ptm(), identity), 1.0) + assert_close(gate_error(kraus.to_ptm(), identity), 0.0) + + superop = kraus.to_superop() + assert isinstance(superop, SuperOp) + assert_close(process_fidelity(superop.to_ptm(), identity), 1.0) + + chi = kraus.to_chi() + assert isinstance(chi, ChiMatrix) + assert_close(process_fidelity(chi.to_ptm(), identity), 1.0) + + stinespring = kraus.to_stinespring() + assert isinstance(stinespring, Stinespring) + assert stinespring.environment_dim() == 1 + assert_close(process_fidelity(stinespring.to_kraus().to_ptm(), identity), 1.0) + + +def test_superop_compose_and_tensor_wrappers() -> None: + identity = Ptm.identity(1).to_superop() + x_channel = PauliChannel.one_qubit(1.0, 0.0, 0.0).to_ptm().to_superop() + + composed = x_channel.compose(x_channel) + assert isinstance(composed, SuperOp) + assert composed.num_qubits() == 1 + assert_matrix_close(composed.matrix(), identity.matrix()) + + tensor = identity.tensor(identity) + assert tensor.num_qubits() == 2 + assert_matrix_close( + tensor.matrix(), + [[1.0 + 0.0j if row == col else 0.0 + 0.0j for col in range(16)] for row in range(16)], + ) + + scalar_identity = Ptm.identity(0).to_superop() + assert scalar_identity.num_qubits() == 0 + assert_matrix_close(scalar_identity.matrix(), [[1.0 + 0.0j]]) + + with pytest.raises(ValueError, match="channel qubit count mismatch"): + identity.compose(tensor) + + +def test_zero_qubit_channel_wrappers_round_trip_scalar_identity() -> None: + identity = Ptm.identity(0) + + assert identity.num_qubits() == 0 + assert identity.matrix() == [[1.0]] + + choi = identity.to_choi() + assert choi.num_qubits() == 0 + assert_matrix_close(choi.matrix(), [[1.0 + 0.0j]]) + + kraus = identity.to_kraus() + assert kraus.num_qubits() == 0 + assert kraus.operators() == [[[1.0 + 0.0j]]] + + superop = identity.to_superop() + assert superop.num_qubits() == 0 + assert_matrix_close(superop.matrix(), [[1.0 + 0.0j]]) + + chi = identity.to_chi() + assert chi.num_qubits() == 0 + assert_matrix_close(chi.matrix(), [[1.0 + 0.0j]]) + + stinespring = kraus.to_stinespring() + assert stinespring.num_qubits() == 0 + assert stinespring.environment_dim() == 1 + assert_matrix_close(stinespring.isometry(), [[1.0 + 0.0j]]) + + +def test_process_tomography_design_reconstructs_identity_channel() -> None: + design = ProcessTomographyDesign.matrix_unit(1) + + assert design.num_qubits() == 1 + assert design.dim() == 2 + assert design.num_inputs() == 4 + assert design.input_metadata_all() == [(0, 0, 0), (1, 1, 0), (2, 0, 1), (3, 1, 1)] + assert design.input_index(1, 0) == 1 + assert_matrix_close( + design.input_operator(2), + [[0.0 + 0.0j, 1.0 + 0.0j], [0.0 + 0.0j, 0.0 + 0.0j]], + ) + assert design.input_operators() == matrix_unit_basis(1) + + choi = Ptm.identity(1).to_choi() + outputs = design.simulate_outputs(choi) + reconstructed = design.reconstruct_choi(outputs) + assert_matrix_close(reconstructed.matrix(), choi.matrix()) + assert reconstructed.is_cptp() + assert reconstructed.is_unital() + + +def test_process_tomography_design_reconstructs_random_two_qubit_channel() -> None: + channel = random_quantum_channel(2, 2, 321) + choi = channel.to_choi() + design = ProcessTomographyDesign.matrix_unit(2) + + assert design.dim() == 4 + assert design.num_inputs() == 16 + + outputs = design.simulate_outputs(choi) + reconstructed = design.reconstruct_choi(outputs) + + assert_matrix_close(reconstructed.matrix(), choi.matrix()) + assert reconstructed.is_cptp() + + +def test_choi_from_matrix_unit_outputs_static_constructor() -> None: + outputs = matrix_unit_basis(1) + reconstructed = ChoiMatrix.from_matrix_unit_outputs(1, outputs) + assert_matrix_close(reconstructed.matrix(), Ptm.identity(1).to_choi().matrix()) + + +def test_state_measure_wrappers() -> None: + zero = [1.0 + 0.0j, 0.0 + 0.0j] + plus = [2.0**-0.5 + 0.0j, 2.0**-0.5 + 0.0j] + zero_density = [[1.0 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.0 + 0.0j]] + bell = [2.0**-0.5 + 0.0j, 0.0 + 0.0j, 0.0 + 0.0j, 2.0**-0.5 + 0.0j] + bell_density = [ + [0.5 + 0.0j, 0.0 + 0.0j, 0.0 + 0.0j, 0.5 + 0.0j], + [0.0 + 0.0j, 0.0 + 0.0j, 0.0 + 0.0j, 0.0 + 0.0j], + [0.0 + 0.0j, 0.0 + 0.0j, 0.0 + 0.0j, 0.0 + 0.0j], + [0.5 + 0.0j, 0.0 + 0.0j, 0.0 + 0.0j, 0.5 + 0.0j], + ] + + assert_close(state_fidelity(zero, zero), 1.0) + assert_close(state_fidelity(zero, plus), 0.5) + assert_close(state_fidelity_with_density_matrix(zero_density, zero), 1.0) + assert_close(purity(zero_density), 1.0) + assert_close(entropy(zero_density), 0.0) + assert_close(shannon_entropy([0.5, 0.5], 2.0), 1.0) + assert_close(negativity(bell_density, [2, 2], 1), 0.5) + assert_close(logarithmic_negativity(bell_density, [2, 2], 1), 1.0) + expected_reduced = [[0.5 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.5 + 0.0j]] + assert_matrix_close(partial_trace_qubits(bell_density, 2, [1]), expected_reduced) + assert_matrix_close(partial_trace_subsystems(bell_density, [2, 2], [1]), expected_reduced) + assert_close(hellinger_distance([1.0, 0.0], [0.0, 1.0]), 1.0) + assert_close(hellinger_fidelity([0.25, 0.75], [0.25, 0.75]), 1.0) + schmidt = schmidt_decomposition(bell, [2, 2], [0]) + assert len(schmidt) == 2 + assert_close(schmidt[0][0], 2.0**-0.5) + assert_close(schmidt[1][0], 2.0**-0.5) + + +def test_quantum_info_wrappers_raise_value_errors_for_invalid_inputs() -> None: + with pytest.raises(ValueError, match="vector length mismatch"): + state_fidelity([1.0 + 0.0j], [1.0 + 0.0j, 0.0 + 0.0j]) + + with pytest.raises(ValueError, match="state vector squared norm"): + state_fidelity([1.0 + 0.0j, 1.0 + 0.0j], [1.0 + 0.0j, 0.0 + 0.0j]) + + with pytest.raises(ValueError, match="matrix must be square"): + purity([[1.0 + 0.0j, 0.0 + 0.0j]]) + + with pytest.raises(ValueError, match="probability distribution must sum"): + shannon_entropy([0.25, 0.25], 2.0) + + rho = [[1.0 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 0.0 + 0.0j]] + with pytest.raises(ValueError, match="duplicate subsystem"): + partial_trace_subsystems(rho, [2], [0, 0]) + + with pytest.raises(ValueError, match="outside"): + partial_trace_subsystems(rho, [2], [1]) + + with pytest.raises(ValueError, match="invalid subsystem dimensions"): + schmidt_decomposition([1.0 + 0.0j, 0.0 + 0.0j], [2, 2], [0]) + + with pytest.raises(ValueError, match="invalid matrix shape"): + SuperOp(1, [[1.0 + 0.0j]]) + + with pytest.raises(ValueError, match="not an isometry"): + Stinespring(1, [[2.0 + 0.0j, 0.0 + 0.0j], [0.0 + 0.0j, 2.0 + 0.0j]]) + + with pytest.raises(ValueError, match="qubit count mismatch"): + process_fidelity(Ptm.identity(1), Ptm.identity(2)) + + with pytest.raises(ValueError, match="qubit count mismatch"): + average_gate_fidelity(Ptm.identity(1), Ptm.identity(2)) + + with pytest.raises(ValueError, match="qubit count mismatch"): + gate_error(Ptm.identity(1), Ptm.identity(2)) + + +def test_random_generators_are_seed_reproducible_and_valid() -> None: + rho = random_density_matrix(1, 123) + same_rho = random_density_matrix(1, 123) + different_rho = random_density_matrix(1, 124) + + assert rho == same_rho + assert rho != different_rho + assert_close((rho[0][0] + rho[1][1]).real, 1.0) + + channel = random_quantum_channel(1, 2, 123) + same_channel = random_quantum_channel(1, 2, 123) + assert channel.operators() == same_channel.operators() + assert channel.is_trace_preserving() + + two_qubit = random_quantum_channel(2, 2, 125) + assert two_qubit.num_qubits() == 2 + assert two_qubit.is_trace_preserving() + assert two_qubit.to_superop().num_qubits() == 2 + assert len(two_qubit.to_superop().matrix()) == 16 diff --git a/python/quantum-pecos/tests/pecos/test_selene_plugin_workspace.py b/python/quantum-pecos/tests/pecos/test_selene_plugin_workspace.py index 822518776..60e5fcb12 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_plugin_workspace.py +++ b/python/quantum-pecos/tests/pecos/test_selene_plugin_workspace.py @@ -7,7 +7,7 @@ try: import tomllib except ModuleNotFoundError: # pragma: no cover - import tomli as tomllib + import tomli as tomllib # type: ignore[no-redef] def _repo_root() -> Path: 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 405674c1d..1e7ec9cb4 100644 --- a/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py +++ b/python/quantum-pecos/tests/pecos/test_selene_sim_parity.py @@ -18,6 +18,7 @@ import os import tempfile from collections import Counter, defaultdict +from collections.abc import Iterable from pathlib import Path import pytest @@ -187,6 +188,13 @@ def _collect_selene_named_results( ) -> dict[str, list[int] | list[list[int]]]: from selene_sim import DepolarizingErrorModel, SimpleRuntime, Stim + def result_values(values: object) -> list[int]: + if isinstance(values, int): + return [int(values)] + if isinstance(values, Iterable): + return [int(v) for v in values] + return [int(values)] + results: dict[str, list[int] | list[list[int]]] = defaultdict(list) try: for shot_results in instance.run_shots( @@ -205,7 +213,7 @@ def _collect_selene_named_results( ): shot_rows: dict[str, list[int]] = defaultdict(list) for name, values in shot_results: - shot_rows[name].extend(int(v) for v in values) + shot_rows[name].extend(result_values(values)) for name, values in shot_rows.items(): # Match ShotMap.to_dict(): one-bit registers become a flat list across # shots, while vector-valued registers remain nested by shot. @@ -230,7 +238,7 @@ def _collect_selene_named_results_with_custom_noise( p1: float, p2: float, p_meas: float, - p_init: float, + p_prep: float, seed: int, ) -> dict[str, list[int] | list[list[int]]]: from selene_sim import DepolarizingErrorModel, SimpleRuntime, Stim @@ -245,7 +253,7 @@ def _collect_selene_named_results_with_custom_noise( p_1q=p1, p_2q=p2, p_meas=p_meas, - p_init=p_init, + p_init=p_prep, ), runtime=SimpleRuntime(), random_seed=seed, @@ -363,8 +371,9 @@ def test_surface_memory_selene_backends_return_same_register_shapes(basis: str) seed=123, ) - assert set(sim_results) == {"final", "synx", "synz"} - assert set(selene_results) == {"final", "synx", "synz"} + expected_registers = {"final", "synx", "synz"} + assert expected_registers.issubset(sim_results) + assert expected_registers.issubset(selene_results) for key in ("final", "synx", "synz"): assert len(sim_results[key]) == 2 @@ -496,7 +505,7 @@ def test_tiny_syndrome_memory_p2_only_matches_between_selene_backends_statistica p1=0.0, p2=p2, p_meas=0.0, - p_init=0.0, + p_prep=0.0, seed=123, ) diff --git a/python/quantum-pecos/tests/pecos/unit/test_qasm_to_phir_json.py b/python/quantum-pecos/tests/pecos/unit/test_qasm_to_phir_json.py index 7cb11c255..d4a07dcea 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_qasm_to_phir_json.py +++ b/python/quantum-pecos/tests/pecos/unit/test_qasm_to_phir_json.py @@ -157,11 +157,9 @@ def _run_phir_json(phir: dict, *, shots: int = 1, seed: int = 42) -> dict: from pecos_rslib import RustPhirClassicalInterpreter py_i = PhirClassicalInterpreter() - py_i.phir_validate = False py_r = HybridEngine(cinterp=py_i).run(phir, shots=shots, seed=seed, return_int=True) rs_i = RustPhirClassicalInterpreter() - rs_i.phir_validate = False rs_r = HybridEngine(cinterp=rs_i).run(phir, shots=shots, seed=seed, return_int=True) py_vals = {k: int(v[0]) for k, v in py_r.items()} diff --git a/python/quantum-pecos/tests/pecos/unit/test_rust_python_parity.py b/python/quantum-pecos/tests/pecos/unit/test_rust_python_parity.py index eb49bc872..d6671a8b5 100644 --- a/python/quantum-pecos/tests/pecos/unit/test_rust_python_parity.py +++ b/python/quantum-pecos/tests/pecos/unit/test_rust_python_parity.py @@ -46,7 +46,6 @@ def run_both( kw["qsim"] = qsim py_i = PhirClassicalInterpreter() - py_i.phir_validate = False py_r = HybridEngine(cinterp=py_i, **kw).run( phir, foreign_object=foreign_object, @@ -56,7 +55,6 @@ def run_both( ) rs_i = RustPhirClassicalInterpreter() - rs_i.phir_validate = False rs_r = HybridEngine(cinterp=rs_i, **kw).run( phir, foreign_object=foreign_object, @@ -106,7 +104,6 @@ def test_wasm_spec_example() -> None: math_wat = WAT_DIR / "math.wat" py_i = PhirClassicalInterpreter() - py_i.phir_validate = False py_r = HybridEngine(cinterp=py_i).run( phir, foreign_object=WasmForeignObject(math_wat), @@ -115,7 +112,6 @@ def test_wasm_spec_example() -> None: ) rs_i = RustPhirClassicalInterpreter() - rs_i.phir_validate = False rs_r = HybridEngine(cinterp=rs_i).run( phir, foreign_object=WasmForeignObject(math_wat), diff --git a/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py new file mode 100644 index 000000000..d368f82d9 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_circuit_fuzz.py @@ -0,0 +1,1042 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Random circuit fuzzing comparing physical and logical PECOS simulations. + +Generates random stabilizer circuits, runs them at two levels: +1. Physical: single-qubit PECOS SparseStab (ground truth) +2. Logical: encoded in a surface code via LogicalCircuitBuilder, + TickCircuit replayed on SparseStab with detector/tracked-Pauli checking + +No Stim dependency. Pure PECOS end-to-end. +""" + +from __future__ import annotations + +import json +import random + +import pytest +from pecos.qec.surface import LogicalCircuitBuilder, SurfacePatch +from pecos_rslib import SparseStab +from pecos_rslib.quantum import TickCircuit + +# --------------------------------------------------------------------------- +# TickCircuit simulation on SparseStab +# --------------------------------------------------------------------------- + + +def simulate_tick_circuit(tc: TickCircuit, seed: int = 0) -> tuple[list[int], int, dict[int, int]]: + """Simulate a TickCircuit on PECOS SparseStab. + + Returns (flat_measurements, det_fired, observable_values). + """ + max_q = 0 + for i in range(tc.num_ticks()): + for g in tc.get_tick(i).gate_batches(): + for q in g.qubits: + max_q = max(max_q, int(q)) + + sim = SparseStab(max_q + 1) + sim.set_seed(seed) + flat = [] + + for i in range(tc.num_ticks()): + for g in tc.get_tick(i).gate_batches(): + name = g.gate_type.name + qs = [int(q) for q in g.qubits] + if name == "QAlloc": + pass + elif name == "PZ": + sim.run_gate("PZ", set(qs)) + elif name == "MZ": + for q in qs: + r = sim.run_gate("MZ", {q}) + flat.append(r.get(q, 0)) + elif name in ("CX", "CZ"): + pairs = {(qs[j], qs[j + 1]) for j in range(0, len(qs), 2)} + sim.run_gate(name, pairs) + else: + sim.run_gate(name, set(qs)) + + num_meas = int(tc.get_meta("num_measurements")) + + # Check detectors + det_fired = 0 + det_json = tc.get_meta("detectors") + if det_json: + for det in json.loads(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(flat): + val ^= flat[idx] + if val != 0: + det_fired += 1 + + # Extract observables + obs_vals = {} + obs_json = tc.get_meta("observables") + if obs_json: + for obs in json.loads(obs_json): + val = 0 + for rec in obs["records"]: + idx = num_meas + rec + if 0 <= idx < len(flat): + val ^= flat[idx] + obs_vals[obs["id"]] = val + + return flat, det_fired, obs_vals + + +def physical_sim_1q(gates: list[str], init_basis: str, meas_basis: str) -> int: + """Single-qubit ground truth on SparseStab.""" + sim = SparseStab(1) + if init_basis == "X": + sim.run_gate("H", {0}) + for g in gates: + sim.run_gate(g, {0}) + if meas_basis == "X": + sim.run_gate("H", {0}) + return sim.run_gate("MZ", {0}).get(0, 0) + + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +@pytest.fixture +def patch(): + return SurfacePatch.create(distance=3) + + +@pytest.fixture +def nq(patch): + return patch.geometry.num_data + patch.geometry.num_ancilla + + +# --------------------------------------------------------------------------- +# Deterministic gate correctness (observable values) +# --------------------------------------------------------------------------- + + +class TestGateCorrectness: + """Verify gate observables match physical ground truth (pure PECOS).""" + + def test_memory_z(self, patch): + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 2, "Z") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + + def test_memory_x(self, patch): + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 2, "X") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + + def test_h_z_to_x(self, patch): + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 2, "Z") + b.add_transversal_h("A") + b.add_memory("A", 2, "X") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + + def test_h_x_to_z(self, patch): + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 2, "X") + b.add_transversal_h("A") + b.add_memory("A", 2, "Z") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + + def test_hh_identity(self, patch): + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 2, "Z") + b.add_transversal_h("A") + b.add_memory("A", 2, "X") + b.add_transversal_h("A") + b.add_memory("A", 2, "Z") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + + def test_cx_00_zz(self, patch, nq): + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 2, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 2, "Z") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + assert obs[1] == 0 + + def test_cx_pp_xx(self, patch, nq): + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 2, "X") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 2, "X") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + assert obs[1] == 0 + + +# --------------------------------------------------------------------------- +# Noiseless detector validity (multiple seeds) +# --------------------------------------------------------------------------- + + +class TestNoiselessDetectors: + """Verify 0 detector fires across many seeds (PECOS-only).""" + + @pytest.mark.parametrize("seed", range(20)) + def test_memory_z(self, patch, seed): + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 3, "Z") + _, det, _ = simulate_tick_circuit(b.to_tick_circuit(), seed) + assert det == 0 + + @pytest.mark.parametrize("seed", range(20)) + def test_h(self, patch, seed): + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 2, "Z") + b.add_transversal_h("A") + b.add_memory("A", 2, "X") + _, det, _ = simulate_tick_circuit(b.to_tick_circuit(), seed) + assert det == 0 + + @pytest.mark.parametrize("seed", range(20)) + def test_cx(self, patch, nq, seed): + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 2, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 2, "Z") + _, det, _ = simulate_tick_circuit(b.to_tick_circuit(), seed) + assert det == 0 + + +# --------------------------------------------------------------------------- +# Fuzz: physical vs logical H sequences +# --------------------------------------------------------------------------- + + +class TestFuzzH: + @pytest.mark.parametrize("seed", range(50)) + def test_random_h(self, patch, seed): + rng = random.Random(seed) + num_h = rng.randint(0, 8) + init_b = rng.choice(["Z", "X"]) + eff_b = init_b + for _ in range(num_h): + eff_b = "X" if eff_b == "Z" else "Z" + + expected = physical_sim_1q(["H"] * num_h, init_b, eff_b) + + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 2, init_b) + cur = init_b + for _ in range(num_h): + b.add_transversal_h("A") + cur = "X" if cur == "Z" else "Z" + b.add_memory("A", 2, cur) + + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == expected + + +# --------------------------------------------------------------------------- +# Fuzz: H+CX composition +# --------------------------------------------------------------------------- + + +class TestFuzzCX: + """Fuzz CX with various init/meas bases against physical SparseStab.""" + + @pytest.mark.parametrize("seed", range(50)) + def test_random_cx(self, patch, nq, seed): + rng = random.Random(seed) + ic = rng.choice(["Z", "X"]) + it = rng.choice(["Z", "X"]) + mc = rng.choice(["Z", "X"]) + mt = rng.choice(["Z", "X"]) + + # Physical ground truth: detect deterministic outcomes + results = [] + for _ in range(50): + sim = SparseStab(2) + if ic == "X": + sim.run_gate("H", {0}) + if it == "X": + sim.run_gate("H", {1}) + sim.run_gate("CX", {(0, 1)}) + if mc == "X": + sim.run_gate("H", {0}) + if mt == "X": + sim.run_gate("H", {1}) + r = sim.run_gate("MZ", {0, 1}) + results.append((r.get(0, 0), r.get(1, 0))) + r0s, r1s = [r[0] for r in results], [r[1] for r in results] + exp0 = r0s[0] if len(set(r0s)) == 1 else None + exp1 = r1s[0] if len(set(r1s)) == 1 else None + + # Encoded + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 2, basis={"C": ic, "T": it}) + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 2, basis={"C": mc, "T": mt}) + + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0, f"Noiseless det fired: {ic}{it}->{mc}{mt}" + + if exp0 is not None and 0 in obs: + assert obs[0] == exp0, f"obs0: got {obs[0]} expected {exp0}" + if exp1 is not None and 1 in obs: + assert obs[1] == exp1, f"obs1: got {obs[1]} expected {exp1}" + + +class TestFuzzComposition: + @pytest.mark.parametrize("seed", range(30)) + def test_random_h_cx(self, patch, nq, seed): + rng = random.Random(seed) + num_ops = rng.randint(1, 6) + + b = LogicalCircuitBuilder() + b.add_patch(patch, "A", qubit_offset=0) + b.add_patch(patch, "B", qubit_offset=nq) + b.add_memory(["A", "B"], 2, "Z") + eff = {"A": "Z", "B": "Z"} + + for _ in range(num_ops): + op = rng.choice(["H_A", "H_B", "CX"]) + if op == "H_A": + b.add_transversal_h("A") + eff["A"] = "X" if eff["A"] == "Z" else "Z" + b.add_memory(["A", "B"], 2, basis={"A": eff["A"], "B": eff["B"]}) + elif op == "H_B": + b.add_transversal_h("B") + eff["B"] = "X" if eff["B"] == "Z" else "Z" + b.add_memory(["A", "B"], 2, basis={"A": eff["A"], "B": eff["B"]}) + else: + if eff["A"] != eff["B"]: + continue + b.add_transversal_cx("A", "B") + b.add_memory(["A", "B"], 2, basis={"A": eff["A"], "B": eff["B"]}) + + _, det, _ = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + + +# --------------------------------------------------------------------------- +# Distance scaling +# --------------------------------------------------------------------------- + + +class TestDistanceScaling: + @pytest.fixture + def patch5(self): + return SurfacePatch.create(distance=5) + + @pytest.fixture + def nq5(self, patch5): + return patch5.geometry.num_data + patch5.geometry.num_ancilla + + def test_d5_memory(self, patch5): + b = LogicalCircuitBuilder() + b.add_patch(patch5, "A") + b.add_memory("A", 3, "Z") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + + def test_d5_h(self, patch5): + b = LogicalCircuitBuilder() + b.add_patch(patch5, "A") + b.add_memory("A", 2, "Z") + b.add_transversal_h("A") + b.add_memory("A", 2, "X") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + + @pytest.mark.parametrize("seed", range(5)) + def test_d5_cx(self, patch5, nq5, seed): + b = LogicalCircuitBuilder() + b.add_patch(patch5, "C", qubit_offset=0) + b.add_patch(patch5, "T", qubit_offset=nq5) + b.add_memory(["C", "T"], 2, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 2, "Z") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit(), seed) + assert det == 0 + assert obs[0] == 0 + assert obs[1] == 0 + + def test_d5_pecos_dem(self, patch5): + b = LogicalCircuitBuilder() + b.add_patch(patch5, "A") + b.add_memory("A", 2, "Z") + dem_str = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + errors = [line for line in dem_str.split("\n") if line.startswith("error(")] + assert len(errors) > 0 + + +# --------------------------------------------------------------------------- +# TickCircuit structural tests +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# SZ teleportation +# --------------------------------------------------------------------------- + + +class TestReliableObservables: + """Verify that non-reliable observables are correctly skipped.""" + + def test_cx_same_basis_both_reliable(self, patch, nq): + """CX with same-basis measurements: both observables emitted.""" + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 2, "Z") + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 2, "Z") + tc = b.to_tick_circuit() + obs = json.loads(tc.get_meta("observables")) + assert len(obs) == 2, f"Expected 2 observables, got {len(obs)}" + + def test_cx_cross_basis_skips_unreliable(self, patch, nq): + """CX with cross-basis: non-deterministic observables skipped.""" + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 2, basis={"C": "X", "T": "Z"}) + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 2, basis={"C": "X", "T": "Z"}) + tc = b.to_tick_circuit() + obs = json.loads(tc.get_meta("observables")) + # Ctrl measured in X after CX: X_ctrl entangled with tgt (measured Z) + # → ctrl X observable is non-reliable → skipped + # Tgt measured in Z after CX: Z_tgt entangled with ctrl (measured X) + # → tgt Z observable is non-reliable → skipped + # Both should be skipped + assert len(obs) == 0, f"Expected 0 observables (both non-reliable), got {len(obs)}" + + def test_cx_zx_one_reliable(self, patch, nq): + """CX|0>|+> with meas(Z,X): both should be reliable. + + After CX: Z_ctrl unchanged, X_tgt unchanged. + Measuring ctrl in Z: reliable (Z not entangled). + Measuring tgt in X: reliable (X not entangled). + """ + b = LogicalCircuitBuilder() + b.add_patch(patch, "C", qubit_offset=0) + b.add_patch(patch, "T", qubit_offset=nq) + b.add_memory(["C", "T"], 2, basis={"C": "Z", "T": "X"}) + b.add_transversal_cx("C", "T") + b.add_memory(["C", "T"], 2, basis={"C": "Z", "T": "X"}) + tc = b.to_tick_circuit() + obs = json.loads(tc.get_meta("observables")) + assert len(obs) == 2, f"Expected 2 observables, got {len(obs)}" + + _, det, obs_vals = simulate_tick_circuit(tc) + assert det == 0 + assert obs_vals[0] == 0 + assert obs_vals[1] == 0 + + +class TestSZTeleportation: + def test_sz_preserves_z(self, patch, nq): + """SZ|0> = |0>: Z eigenvalue preserved.""" + b = LogicalCircuitBuilder() + b.add_patch(patch, "D", qubit_offset=0) + b.add_patch(patch, "Y", qubit_offset=nq) + b.add_memory("D", 2, "Z") + b.add_sz_via_teleportation("D", "Y", 2, 2) + b.add_memory("D", 2, "Z") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + + def test_sz_phase_single_qubit(self): + """Verify SZ teleportation protocol at the single-qubit level. + + The phase test (SZ^2|+> = Z|+> = |->) cannot be verified at the + logical level because the |+Y> injection is non-fault-tolerant + (distance-1 error). But we CAN verify the protocol works on a + single physical qubit, confirming the circuit structure is correct. + """ + sim = SparseStab(3) + # Qubit 0: data (|+>), Qubit 1: ancilla 1 (|+Y>), Qubit 2: ancilla 2 (|+Y>) + + # Prep |+> + sim.run_gate("H", {0}) + # Prep |+Y> = S|+> + sim.run_gate("H", {1}) + sim.run_gate("SZ", {1}) + sim.run_gate("H", {2}) + sim.run_gate("SZ", {2}) + + # First teleportation: CX(data, anc1), measure anc1 in Z + sim.run_gate("CX", {(0, 1)}) + r1 = sim.run_gate("MZ", {1}) + m1 = r1.get(1, 0) + + # Second teleportation: CX(data, anc2), measure anc2 in Z + sim.run_gate("CX", {(0, 2)}) + r2 = sim.run_gate("MZ", {2}) + m2 = r2.get(2, 0) + + # Measure data in X basis: SZ^2|+> = Z|+> = |-> + sim.run_gate("H", {0}) + r_data = sim.run_gate("MZ", {0}) + data_val = r_data.get(0, 0) + + # Corrected observable: data_X XOR m1 XOR m2 + corrected = data_val ^ m1 ^ m2 + assert ( + corrected == 1 + ), f"SZ^2|+> should give |-> (corrected=1), got data={data_val} m1={m1} m2={m2} corrected={corrected}" + + +# --------------------------------------------------------------------------- +# Gate composition +# --------------------------------------------------------------------------- + + +class TestGateComposition: + def test_h_cx_h(self, patch, nq): + """H -> CX -> H on both patches.""" + b = LogicalCircuitBuilder() + b.add_patch(patch, "A", qubit_offset=0) + b.add_patch(patch, "B", qubit_offset=nq) + b.add_memory(["A", "B"], 2, "Z") + b.add_transversal_h("A") + b.add_transversal_h("B") + b.add_memory(["A", "B"], 2, "X") + b.add_transversal_cx("A", "B") + b.add_memory(["A", "B"], 2, "X") + b.add_transversal_h("A") + b.add_transversal_h("B") + b.add_memory(["A", "B"], 2, "Z") + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + assert obs[1] == 0 + + def test_triple_h(self, patch): + """HHH = H: |0> -> |+>.""" + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 2, "Z") + for i in range(3): + b.add_transversal_h("A") + cur = "X" if i % 2 == 0 else "Z" + b.add_memory("A", 2, cur) + _, det, obs = simulate_tick_circuit(b.to_tick_circuit()) + assert det == 0 + assert obs[0] == 0 + + +# --------------------------------------------------------------------------- +# Decoder pipeline (PECOS-native) +# --------------------------------------------------------------------------- + + +class TestDecoderPipeline: + """Test the full decode pipeline using PECOS DEM + PECOS sampler.""" + + def test_build_decoder_pecos_dem(self, patch): + """build_decoder with PECOS-native DEM produces a working decoder.""" + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 3, "Z") + _, decoder = b.build_decoder(p1=0.001, p2=0.001, p_meas=0.001, use_stim_dem=False) + assert decoder.num_observables() == 1 + + def test_pecos_dem_decode_memory(self, patch): + """End-to-end: PECOS DEM → PECOS sample → decode → low error rate.""" + from pecos_rslib.qec import ParsedDem + + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 3, "Z") + dem_str = b.build_dem(p1=0.001, p2=0.001, p_meas=0.001) + + parsed = ParsedDem.from_string(dem_str) + rust_sampler = parsed.to_dem_sampler() + batch = rust_sampler.generate_samples(5000, seed=42) + errors = batch.decode_count(dem_str, "pecos_uf:fast") + ler = errors / 5000 + # 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 + + 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) + sc = b.stab_coords() + osd = ObservableSubgraphDecoder(dem_str, sc, "pecos_uf:fast") + assert osd.num_observables() == 2 + + sizes = osd.subgraph_sizes() + for s in sizes: + assert s > 0, "Empty subgraph" + + def test_pecos_dem_cx_decode(self, patch, nq): + """End-to-end CX: PECOS DEM → sample → decode.""" + from pecos_rslib.qec import ParsedDem + + 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) + parsed = ParsedDem.from_string(dem_str) + rust_sampler = parsed.to_dem_sampler() + batch = rust_sampler.generate_samples(5000, seed=42) + errors = batch.decode_count(dem_str, "pecos_uf:fast") + ler = errors / 5000 + assert ler < 0.1, f"CX LER too high: {ler}" + + +# --------------------------------------------------------------------------- +# TickCircuit structural tests +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Threshold: error suppression with distance (d=3 vs d=5) +# --------------------------------------------------------------------------- + + +class TestThreshold: + """Verify error suppression increases with distance. + + Uses Stim DEM for error mechanisms (more complete noise model) + and PECOS decoder for correction. Tests at p=0.001 where we should + be well below threshold. + """ + + def _run_threshold(self, builder, _d, decoder_type="pecos_uf:fast"): + import stim + from pecos_rslib.qec import ParsedDem + + c = stim.Circuit(builder.to_stim(p1=0.001, p2=0.001, p_meas=0.001)) + dem = c.detector_error_model(decompose_errors=True, ignore_decomposition_failures=True) + dem_str = str(dem) + parsed = ParsedDem.from_string(dem_str) + sampler = parsed.to_dem_sampler() + batch = sampler.generate_samples(20000, seed=42) + errors = batch.decode_count(dem_str, decoder_type) + return errors / 20000 + + def test_memory_suppression(self): + """Memory: d=5 should have lower LER than d=3.""" + lers = {} + for d in [3, 5]: + patch = SurfacePatch.create(distance=d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds=d, basis="Z") + lers[d] = self._run_threshold(b, d, "pymatching") + assert lers[5] < lers[3], f"d=5 ({lers[5]}) not better than d=3 ({lers[3]})" + + def test_h_suppression(self): + """H gate: d=5 should have lower LER than d=3.""" + lers = {} + for d in [3, 5]: + patch = SurfacePatch.create(distance=d) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", rounds=d, basis="Z") + b.add_transversal_h("A") + b.add_memory("A", rounds=d, basis="X") + lers[d] = self._run_threshold(b, d, "pymatching") + assert lers[5] < lers[3], f"d=5 ({lers[5]}) not better than d=3 ({lers[3]})" + + +# --------------------------------------------------------------------------- +# TickCircuit structural tests +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Noisy fuzz: random circuits decode with reasonable error rates +# --------------------------------------------------------------------------- + + +class TestNoisyFuzz: + """Verify that random circuits with noise decode with sub-50% error rate. + + This is a basic sanity check: if the decoder is completely broken, + the LER would be ~50%. Any reasonable decoder should do much better. + """ + + @pytest.mark.parametrize("seed", range(5)) + def test_noisy_h(self, patch, seed): + import stim + from pecos_rslib.qec import ParsedDem + + rng = random.Random(seed) + num_h = rng.randint(1, 4) + init_b = "Z" + b = LogicalCircuitBuilder() + b.add_patch(patch, "A") + b.add_memory("A", 2, init_b) + cur = init_b + for _ in range(num_h): + b.add_transversal_h("A") + cur = "X" if cur == "Z" else "Z" + b.add_memory("A", 2, cur) + + c = stim.Circuit(b.to_stim(p1=0.001, p2=0.001, p_meas=0.001)) + dem = c.detector_error_model(decompose_errors=True) + dem_str = str(dem) + parsed = ParsedDem.from_string(dem_str) + batch = parsed.to_dem_sampler().generate_samples(5000, seed=seed) + errors = batch.decode_count(dem_str, "pecos_uf:fast") + ler = errors / 5000 + assert ler < 0.1, f"LER too high: {ler}" + + +# --------------------------------------------------------------------------- +# TickCircuit structural tests +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Composed noisy gate sequences +# --------------------------------------------------------------------------- + + +class TestNoisyComposition: + """Verify that multi-gate sequences with noise decode correctly.""" + + def test_noisy_h_cx_h(self, patch, nq): + """H -> CX -> H with noise: should decode with low LER.""" + import stim + from pecos_rslib.qec import ParsedDem + + b = LogicalCircuitBuilder() + b.add_patch(patch, "A", qubit_offset=0) + b.add_patch(patch, "B", qubit_offset=nq) + b.add_memory(["A", "B"], 2, "Z") + b.add_transversal_h("A") + b.add_transversal_h("B") + b.add_memory(["A", "B"], 2, "X") + b.add_transversal_cx("A", "B") + b.add_memory(["A", "B"], 2, "X") + b.add_transversal_h("A") + b.add_transversal_h("B") + b.add_memory(["A", "B"], 2, "Z") + + # Use Stim DEM (more error mechanisms) for noisy test + c = stim.Circuit(b.to_stim(p1=0.001, p2=0.001, p_meas=0.001)) + dem = c.detector_error_model(decompose_errors=True, ignore_decomposition_failures=True) + dem_str = str(dem) + parsed = ParsedDem.from_string(dem_str) + batch = parsed.to_dem_sampler().generate_samples(10000, seed=42) + errors = batch.decode_count(dem_str, "pecos_uf:fast") + ler = errors / 10000 + assert ler < 0.1, f"H-CX-H LER too high: {ler}" + + @pytest.mark.parametrize("seed", range(5)) + def test_noisy_random_composition(self, patch, nq, seed): + """Random H+CX with noise: should decode with sub-50% LER.""" + import stim + from pecos_rslib.qec import ParsedDem + + rng = random.Random(seed) + b = LogicalCircuitBuilder() + b.add_patch(patch, "A", qubit_offset=0) + b.add_patch(patch, "B", qubit_offset=nq) + b.add_memory(["A", "B"], 2, "Z") + eff = {"A": "Z", "B": "Z"} + + for _ in range(rng.randint(1, 4)): + op = rng.choice(["H_A", "H_B", "CX"]) + if op == "H_A": + b.add_transversal_h("A") + eff["A"] = "X" if eff["A"] == "Z" else "Z" + b.add_memory(["A", "B"], 2, basis={"A": eff["A"], "B": eff["B"]}) + elif op == "H_B": + b.add_transversal_h("B") + eff["B"] = "X" if eff["B"] == "Z" else "Z" + b.add_memory(["A", "B"], 2, basis={"A": eff["A"], "B": eff["B"]}) + else: + if eff["A"] != eff["B"]: + continue + b.add_transversal_cx("A", "B") + b.add_memory(["A", "B"], 2, basis={"A": eff["A"], "B": eff["B"]}) + + c = stim.Circuit(b.to_stim(p1=0.001, p2=0.001, p_meas=0.001)) + dem = c.detector_error_model(decompose_errors=True, ignore_decomposition_failures=True) + dem_str = str(dem) + 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.2, f"Random composition LER too high: {ler}" + + +# --------------------------------------------------------------------------- +# OSD accuracy comparison +# --------------------------------------------------------------------------- + + +class TestOSDAccuracy: + """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.""" + import stim + from pecos_rslib.qec import ObservableSubgraphDecoder, ParsedDem + + 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") + + c = stim.Circuit(b.to_stim(p1=0.001, p2=0.001, p_meas=0.001)) + dem = c.detector_error_model(ignore_decomposition_failures=True) + dem_str = str(dem) + + # Sample + sampler = dem.compile_sampler() + det_events, obs_flips, _ = sampler.sample(20000) + + # Naive: decomposed MWPM via PECOS UF + dem_decomp = c.detector_error_model(decompose_errors=True, ignore_decomposition_failures=True) + parsed = ParsedDem.from_string(str(dem_decomp)) + batch_naive = parsed.to_dem_sampler().generate_samples(20000, seed=42) + naive_errors = batch_naive.decode_count(str(dem_decomp), "pecos_uf:fast") + naive_ler = naive_errors / 20000 + + # OSD with FB + sc = b.stab_coords() + osd = ObservableSubgraphDecoder(dem_str, sc, "fusion_blossom_serial") + osd_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]) + ) + osd_ler = osd_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})" + + +# --------------------------------------------------------------------------- +# PECOS-native DEM with OSD decoder on CX +# --------------------------------------------------------------------------- + + +class TestPecosDemWithOSD: + """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 + + 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) + + # Verify DEM has content + errors = [line for line in dem_str.split("\n") if line.startswith("error(")] + assert len(errors) > 0 + + # Build OSD decoder from PECOS DEM + sc = b.stab_coords() + osd = ObservableSubgraphDecoder(dem_str, sc, "pecos_uf:fast") + assert osd.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}" + + +# --------------------------------------------------------------------------- +# TickCircuit structural tests +# --------------------------------------------------------------------------- + +# --------------------------------------------------------------------------- +# Mirrored brickwork circuits +# --------------------------------------------------------------------------- + + +def _build_mirrored_brickwork(num_qubits, depth, seed, patch, rounds=2): + """Build a mirrored brickwork circuit (identity, output = |0...0>). + + Forward: random H gates + CX brickwork layers. + Mirror: exact reverse (H and CX are self-inverse). + """ + nq = patch.geometry.num_data + patch.geometry.num_ancilla + + b = LogicalCircuitBuilder() + labels = [f"Q{i}" for i in range(num_qubits)] + for i, label in enumerate(labels): + b.add_patch(patch, label, qubit_offset=i * nq) + + eff = dict.fromkeys(labels, "Z") + b.add_memory(labels, rounds, "Z") + + rng = random.Random(seed) + ops_forward = [] + + for layer in range(depth): + layer_ops = [] + for label in labels: + # H is the only single-qubit logical gate available. + # SZ requires teleportation (not a standalone gate on the surface code). + if rng.random() < 0.5: + b.add_transversal_h(label) + eff[label] = "X" if eff[label] == "Z" else "Z" + layer_ops.append(("H", label)) + b.add_memory(labels, rounds, basis={label: eff[label] for label in labels}) + + offset = layer % 2 + cx_applied = [] + for i in range(offset, num_qubits - 1, 2): + ctrl, tgt = labels[i], labels[i + 1] + if eff[ctrl] == eff[tgt]: + b.add_transversal_cx(ctrl, tgt) + cx_applied.append((ctrl, tgt)) + if cx_applied: + b.add_memory(labels, rounds, basis={label: eff[label] for label in labels}) + layer_ops.append(("CX", cx_applied)) + ops_forward.append(layer_ops) + + for layer_ops in reversed(ops_forward): + for op_type, *args in reversed(layer_ops): + if op_type == "CX": + for ctrl, tgt in reversed(args[0]): + if eff[ctrl] == eff[tgt]: + b.add_transversal_cx(ctrl, tgt) + b.add_memory(labels, rounds, basis={label: eff[label] for label in labels}) + for op_type, *args in reversed(layer_ops): + if op_type == "H": + label = args[0] + b.add_transversal_h(label) + eff[label] = "X" if eff[label] == "Z" else "Z" + b.add_memory(labels, rounds, basis={label: eff[label] for label in labels}) + + return b + + +class TestMirroredBrickwork: + """Mirrored brickwork circuits: identity circuit, output always |0...0>. + + Tests random H + CX brickwork layers at various widths and depths. + The mirror guarantees the output is |0...0> regardless of random choices. + """ + + @pytest.mark.parametrize("width", [2, 3, 4]) + @pytest.mark.parametrize("depth", [1, 2, 3]) + @pytest.mark.parametrize("seed", range(5)) + def test_brickwork_d3(self, width, depth, seed): + patch = SurfacePatch.create(distance=3) + b = _build_mirrored_brickwork(width, depth, seed, patch) + tc = b.to_tick_circuit() + det_fired, obs_vals = simulate_tick_circuit(tc, seed)[-2:] + assert det_fired == 0, f"w={width} d={depth}: {det_fired} detectors fired" + for obs_id, val in obs_vals.items(): + assert val == 0, f"w={width} d={depth}: obs{obs_id}={val}" + + @pytest.mark.parametrize("width", [2, 3]) + @pytest.mark.parametrize("seed", range(3)) + def test_brickwork_d5(self, width, seed): + patch = SurfacePatch.create(distance=5) + b = _build_mirrored_brickwork(width, 2, seed, patch) + tc = b.to_tick_circuit() + det_fired, obs_vals = simulate_tick_circuit(tc, seed)[-2:] + assert det_fired == 0 + for val in obs_vals.values(): + assert val == 0 + + +# --------------------------------------------------------------------------- +# TickCircuit structural tests +# --------------------------------------------------------------------------- + + +class TestTickCircuitStructure: + def test_gate_count_and_gate_batch_count_are_distinct(self): + tc = TickCircuit() + tc.tick().h([0]).h([1]).cx([(2, 3), (4, 5)]) + + tick = tc.get_tick(0) + assert tick.gate_count() == 4 + assert tick.gate_batch_count() == 2 + assert len(tick.gate_batches()) == 2 + assert len(tick) == 2 + + assert tc.gate_count() == 4 + assert tc.gate_batch_count() == 2 + assert len(tc.gate_batches()) == 2 + + @pytest.mark.parametrize("num_qubits", [1, 2, 3, 5, 8]) + @pytest.mark.parametrize("depth", [10, 30]) + @pytest.mark.parametrize("seed", range(3)) + def test_build_roundtrip(self, num_qubits, depth, seed): + gate_set = ["H", "SZ", "X", "Z"] + if num_qubits >= 2: + gate_set.extend(["CX", "CZ"]) + rng = random.Random(seed) + tc = TickCircuit() + t = tc.tick() + t.qalloc(list(range(num_qubits))) + for _ in range(depth): + gate = rng.choice(gate_set) + t = tc.tick() + if gate in ("CX", "CZ"): + q1, q2 = rng.sample(range(num_qubits), 2) + getattr(t, gate.lower())([(q1, q2)]) + else: + q = rng.randint(0, num_qubits - 1) + getattr(t, gate.lower())([q]) + t = tc.tick() + t.mz(list(range(num_qubits))) + assert tc.num_ticks() >= 2 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 18da22565..c89112f6b 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_decoder.py @@ -59,7 +59,7 @@ def test_default_values(self) -> None: assert noise.p1 == 0.0 assert noise.p2 == 0.0 assert noise.p_meas == 0.0 - assert noise.p_init == 0.0 + assert noise.p_prep == 0.0 def test_is_noiseless(self) -> None: """Test is_noiseless property.""" @@ -67,11 +67,11 @@ def test_is_noiseless(self) -> None: assert not NoiseModel(p1=0.01).is_noiseless assert not NoiseModel(p2=0.01).is_noiseless assert not NoiseModel(p_meas=0.01).is_noiseless - assert not NoiseModel(p_init=0.01).is_noiseless + assert not NoiseModel(p_prep=0.01).is_noiseless def test_physical_error_rate(self) -> None: """Test physical_error_rate property.""" - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.005, p_init=0.002) + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.005, p_prep=0.002) assert noise.physical_error_rate == 0.01 # max of all rates @@ -212,7 +212,7 @@ def test_get_dem_caches_circuit_level_dem(self, monkeypatch: pytest.MonkeyPatch) import pecos.qec.surface.decode as decode_module patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_init=0.001) + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) decoder = SurfaceDecoder( patch, num_rounds=3, @@ -304,7 +304,7 @@ def test_generate_dem_from_patch_can_skip_stim_decomposition(self) -> None: from pecos.qec.surface.decode import generate_dem_from_patch patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_init=0.001) + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) full_dem = generate_dem_from_patch(patch, num_rounds=4, noise=noise, basis="X", decompose_errors=False) decomposed_dem = generate_dem_from_patch(patch, num_rounds=4, noise=noise, basis="X", decompose_errors=True) @@ -316,7 +316,7 @@ def test_generate_dem_from_tick_circuit_supports_raw_and_decomposed_output(self) """Native TickCircuit DEM helper should preserve both public output forms.""" patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=4, basis="X") - params = {"p1": 0.001, "p2": 0.01, "p_meas": 0.01, "p_init": 0.001} + params = {"p1": 0.001, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.001} raw_dem = generate_dem_from_tick_circuit(tc, **params, decompose_errors=False) decomposed_dem = generate_dem_from_tick_circuit(tc, **params, decompose_errors=True) @@ -330,8 +330,8 @@ def test_native_circuit_level_dem_threads_ancilla_budget(self) -> None: from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_init=0.001) - params = {"p1": noise.p1, "p2": noise.p2, "p_meas": noise.p_meas, "p_init": noise.p_init} + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + params = {"p1": noise.p1, "p2": noise.p2, "p_meas": noise.p_meas, "p_prep": noise.p_prep} full_tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") batched_tc = generate_tick_circuit_from_patch( @@ -369,8 +369,8 @@ def test_native_circuit_level_dem_cache_respects_patch_geometry(self) -> None: from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder patch = SurfacePatch.create(dx=3, dz=5) - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_init=0.001) - params = {"p1": noise.p1, "p2": noise.p2, "p_meas": noise.p_meas, "p_init": noise.p_init} + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + params = {"p1": noise.p1, "p2": noise.p2, "p_meas": noise.p_meas, "p_prep": noise.p_prep} tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") expected_dem = generate_dem_from_tick_circuit(tc, **params, decompose_errors=False) @@ -383,6 +383,49 @@ def test_native_circuit_level_dem_cache_respects_patch_geometry(self) -> None: assert cached_dem == expected_dem + def test_native_circuit_level_dem_cache_inserts_idle_gates_only_for_idle_noise(self) -> None: + """Shared native DEM caching should make idle locations an explicit noise choice.""" + from pecos.qec.surface.circuit_builder import generate_dem_from_tick_circuit, generate_tick_circuit_from_patch + from pecos.qec.surface.decode import generate_circuit_level_dem_from_builder + + patch = SurfacePatch.create(distance=3) + base_noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) + base_params = { + "p1": base_noise.p1, + "p2": base_noise.p2, + "p_meas": base_noise.p_meas, + "p_prep": base_noise.p_prep, + "decompose_errors": False, + } + + tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") + expected_base_dem = generate_dem_from_tick_circuit(tc, **base_params) + cached_base_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=base_noise, + basis="X", + ) + + idle_noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001, p_idle=0.002) + idle_tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") + idle_tc.fill_idle_gates() + expected_idle_dem = generate_dem_from_tick_circuit( + idle_tc, + **base_params, + p_idle=idle_noise.p_idle, + ) + cached_idle_dem = generate_circuit_level_dem_from_builder( + patch, + num_rounds=2, + noise=idle_noise, + basis="X", + ) + + assert cached_base_dem == expected_base_dem + assert cached_idle_dem == expected_idle_dem + assert cached_idle_dem != cached_base_dem + def test_traced_qis_native_dem_and_sampler_build(self) -> None: """The traced-QIS circuit source should build DEMs and samplers end-to-end.""" from pecos.qec.surface import build_native_sampler @@ -391,7 +434,7 @@ def test_traced_qis_native_dem_and_sampler_build(self) -> None: _require_selene_runtime() patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.001, p2=0.001, p_meas=0.001, p_init=0.001) + noise = NoiseModel(p1=0.001, p2=0.001, p_meas=0.001, p_prep=0.001) dem = generate_circuit_level_dem_from_builder( patch, @@ -426,7 +469,7 @@ def test_traced_qis_native_dem_and_sampler_build(self) -> None: mnm_det_events, mnm_obs_flips = mnm_sampler.sample(4, seed=7) assert mnm_det_events.shape == (4, mnm_sampler.num_detectors) assert mnm_obs_flips.shape == (4, mnm_sampler.num_observables) - assert mnm_sampler.sampling_model == "mnm" + assert mnm_sampler.sampling_model == "influence_dem" # "mnm" remapped to unified sampler influence_sampler = build_native_sampler( patch, @@ -476,7 +519,7 @@ def extract_errors(dem_str: str) -> dict[str, float]: return errors patch = SurfacePatch.create(distance=3) - noise = NoiseModel(p1=0.003, p2=0.003, p_meas=0.003, p_init=0.003) + noise = NoiseModel(p1=0.003, p2=0.003, p_meas=0.003, p_prep=0.003) for basis in ("X", "Z"): tc = _build_surface_tick_circuit_for_native_model( @@ -490,7 +533,7 @@ def extract_errors(dem_str: str) -> dict[str, float]: p1=noise.p1, p2=noise.p2, p_meas=noise.p_meas, - p_init=noise.p_init, + p_prep=noise.p_prep, decompose_errors=False, ) stim_dem = str( @@ -500,7 +543,7 @@ def extract_errors(dem_str: str) -> dict[str, float]: p1=noise.p1, p2=noise.p2, p_meas=noise.p_meas, - p_init=noise.p_init, + p_prep=noise.p_prep, ), ).detector_error_model(decompose_errors=False), ) @@ -531,8 +574,8 @@ def test_traced_qis_native_topology_cache_is_shared_across_public_apis(self) -> _require_selene_runtime() patch = SurfacePatch.create(distance=3) - noise_a = NoiseModel(p1=0.001, p2=0.001, p_meas=0.001, p_init=0.001) - noise_b = NoiseModel(p1=0.002, p2=0.002, p_meas=0.002, p_init=0.002) + noise_a = NoiseModel(p1=0.001, p2=0.001, p_meas=0.001, p_prep=0.001) + noise_b = NoiseModel(p1=0.002, p2=0.002, p_meas=0.002, p_prep=0.002) _cached_surface_native_topology.cache_clear() _cached_surface_native_dem_string.cache_clear() @@ -582,7 +625,7 @@ def test_generate_dem_from_tick_circuit_maximal_decomposition_prefers_singletons """Maximal decomposition should no longer be a no-op.""" patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=20, basis="X") - params = {"p1": 0.0, "p2": 0.00235, "p_meas": 0.01972626855445279, "p_init": 0.0010045162906914633} + params = {"p1": 0.0, "p2": 0.00235, "p_meas": 0.01972626855445279, "p_prep": 0.0010045162906914633} decomposed_dem = generate_dem_from_tick_circuit(tc, **params, decompose_errors=True) maximal_dem = generate_dem_from_tick_circuit( diff --git a/python/quantum-pecos/tests/qec/surface/test_surface_geometry.py b/python/quantum-pecos/tests/qec/surface/test_surface_geometry.py new file mode 100644 index 000000000..e0db9ab13 --- /dev/null +++ b/python/quantum-pecos/tests/qec/surface/test_surface_geometry.py @@ -0,0 +1,325 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Tests for surface code geometry across all dimensions. + +Verifies that the stabilizer generators, code parameters, CNOT scheduling, +and circuit generation work correctly for: +- Standard odd square codes (d=3,5,7) +- Even square codes (d=2,4) +- Asymmetric codes (dx != dz) +- Repetition codes (dx=1 or dz=1) +- Single qubit (dx=dz=1) +""" + +import pytest +from pecos.qec.surface import SurfacePatch +from pecos.qec.surface.schedule import compute_cnot_schedule + +# ============================================================ +# Code parameters: n, k, d +# ============================================================ + + +@pytest.mark.parametrize( + ("dx", "dz", "expected_n", "expected_k", "expected_d"), + [ + # Single qubit + (1, 1, 1, 1, 1), + # Repetition codes (X checks) + (1, 3, 3, 1, 1), + (1, 5, 5, 1, 1), + (1, 7, 7, 1, 1), + # Repetition codes (Z checks) + (3, 1, 3, 1, 1), + (5, 1, 5, 1, 1), + # Even square + (2, 2, 4, 1, 2), + (4, 4, 16, 1, 4), + # Odd square (standard surface code) + (3, 3, 9, 1, 3), + (5, 5, 25, 1, 5), + (7, 7, 49, 1, 7), + # Asymmetric + (2, 3, 6, 1, 2), + (3, 2, 6, 1, 2), + (3, 5, 15, 1, 3), + (5, 3, 15, 1, 3), + (2, 5, 10, 1, 2), + ], +) +def test_code_parameters(dx, dz, expected_n, expected_k, expected_d): + """Code parameters [[n,k,d]] should be correct for all dimensions.""" + patch = SurfacePatch.create(dx=dx, dz=dz) + n = patch.num_data + k = n - patch.geometry.num_x_stab - patch.geometry.num_z_stab + d = patch.distance + + assert n == expected_n + assert k == expected_k + assert d == expected_d + + +# ============================================================ +# Stabilizer structure +# ============================================================ + + +@pytest.mark.parametrize( + ("dx", "dz", "expected_x", "expected_z"), + [ + (1, 1, 0, 0), + (1, 3, 2, 0), + (3, 1, 0, 2), + (1, 5, 4, 0), + (5, 1, 0, 4), + (2, 2, 2, 1), + (3, 3, 4, 4), + (2, 3, 3, 2), + (3, 2, 2, 3), + (4, 4, 8, 7), + (5, 5, 12, 12), + ], +) +def test_stabilizer_counts(dx, dz, expected_x, expected_z): + """Number of X and Z stabilizers should match expected values.""" + patch = SurfacePatch.create(dx=dx, dz=dz) + assert patch.geometry.num_x_stab == expected_x + assert patch.geometry.num_z_stab == expected_z + + +def test_repetition_code_x_checks(): + """dx=1 repetition code should have only X stabilizers (adjacent XX pairs).""" + patch = SurfacePatch.create(dx=1, dz=5) + assert patch.geometry.num_z_stab == 0 + qubits = [s.data_qubits for s in patch.geometry.x_stabilizers] + # All should be adjacent pairs covering 0..4 + for q1, q2 in qubits: + assert q2 == q1 + 1 + + +def test_repetition_code_z_checks(): + """dz=1 repetition code should have only Z stabilizers (adjacent ZZ pairs).""" + patch = SurfacePatch.create(dx=5, dz=1) + assert patch.geometry.num_x_stab == 0 + qubits = [s.data_qubits for s in patch.geometry.z_stabilizers] + for q1, q2 in qubits: + assert q2 == q1 + 1 + + +def test_all_data_qubits_in_stabilizer_support(): + """Every data qubit (except logical operator edges) should appear in at least one stabilizer.""" + for dx, dz in [(3, 3), (2, 3), (3, 2), (2, 2), (4, 4)]: + patch = SurfacePatch.create(dx=dx, dz=dz) + touched = set() + for s in patch.geometry.x_stabilizers: + touched.update(s.data_qubits) + for s in patch.geometry.z_stabilizers: + touched.update(s.data_qubits) + assert touched == set(range(patch.num_data)), f"Untouched qubits in {dx}x{dz}" + + +def test_stabilizer_weights(): + """Bulk stabilizers should be weight 4, boundary weight 2.""" + for dx, dz in [(3, 3), (5, 5), (2, 3), (3, 5)]: + patch = SurfacePatch.create(dx=dx, dz=dz) + for s in list(patch.geometry.x_stabilizers) + list(patch.geometry.z_stabilizers): + if s.is_boundary: + assert len(s.data_qubits) == 2, f"Boundary stab has weight {len(s.data_qubits)}" + else: + assert len(s.data_qubits) == 4, f"Bulk stab has weight {len(s.data_qubits)}" + + +# ============================================================ +# Logical operators +# ============================================================ + + +def test_logical_x_weight_equals_dx(): + """Logical X should have weight dx (left edge of the grid).""" + for dx, dz in [(3, 3), (3, 5), (5, 3), (2, 3), (1, 5)]: + patch = SurfacePatch.create(dx=dx, dz=dz) + assert len(patch.geometry.logical_x.data_qubits) == dx + + +def test_logical_z_weight_equals_dz(): + """Logical Z should have weight dz (top edge of the grid).""" + for dx, dz in [(3, 3), (3, 5), (5, 3), (2, 3), (1, 5)]: + patch = SurfacePatch.create(dx=dx, dz=dz) + assert len(patch.geometry.logical_z.data_qubits) == dz + + +def test_logical_x_is_left_edge(): + """Logical X qubits should be column 0 of the dx x dz grid.""" + for dx, dz in [(3, 3), (3, 5), (2, 3)]: + patch = SurfacePatch.create(dx=dx, dz=dz) + expected = tuple(i * dz for i in range(dx)) + assert patch.geometry.logical_x.data_qubits == expected + + +def test_logical_z_is_top_edge(): + """Logical Z qubits should be row 0 of the dx x dz grid.""" + for dx, dz in [(3, 3), (3, 5), (2, 3)]: + patch = SurfacePatch.create(dx=dx, dz=dz) + expected = tuple(range(dz)) + assert patch.geometry.logical_z.data_qubits == expected + + +# ============================================================ +# CNOT schedule: no conflicts +# ============================================================ + + +@pytest.mark.parametrize( + ("dx", "dz"), + [ + (1, 3), + (3, 1), + (2, 2), + (2, 3), + (3, 2), + (3, 3), + (3, 5), + (5, 3), + (4, 4), + (5, 5), + ], +) +def test_cnot_schedule_no_conflicts(dx, dz): + """No data qubit should be touched twice in the same CNOT round.""" + patch = SurfacePatch.create(dx=dx, dz=dz) + schedule = compute_cnot_schedule(patch) + for rnd_idx, rnd in enumerate(schedule): + data_qubits = [dq for _, _, dq in rnd] + assert len(data_qubits) == len( + set(data_qubits), + ), f"{dx}x{dz} round {rnd_idx}: data qubit collision {data_qubits}" + + +# ============================================================ +# Backward compatibility: square odd codes unchanged +# ============================================================ + + +def test_square_odd_codes_match_original(): + """The generalized generators should produce identical results to the + original single-d generators for square odd codes. + """ + from pecos.qec.surface.layouts.rotated_lattice import ( + compute_rotated_x_stabilizers, + compute_rotated_z_stabilizers, + get_rotated_logical_x, + get_rotated_logical_z, + ) + + for d in [3, 5, 7]: + x_single = compute_rotated_x_stabilizers(d) + x_pair = compute_rotated_x_stabilizers(d, d) + z_single = compute_rotated_z_stabilizers(d) + z_pair = compute_rotated_z_stabilizers(d, d) + + assert len(x_single) == len(x_pair) + assert len(z_single) == len(z_pair) + + for a, b in zip(x_single, x_pair, strict=False): + assert a.data_qubits == b.data_qubits + assert a.is_boundary == b.is_boundary + + for a, b in zip(z_single, z_pair, strict=False): + assert a.data_qubits == b.data_qubits + assert a.is_boundary == b.is_boundary + + assert get_rotated_logical_x(d) == get_rotated_logical_x(d, d) + assert get_rotated_logical_z(d) == get_rotated_logical_z(d, d) + + +# ============================================================ +# Transposition symmetry +# ============================================================ + + +def test_transpose_swaps_x_and_z_counts(): + """Swapping dx and dz should swap the number of X and Z stabilizers.""" + for dx, dz in [(2, 3), (3, 5), (2, 5), (1, 3), (1, 5)]: + p1 = SurfacePatch.create(dx=dx, dz=dz) + p2 = SurfacePatch.create(dx=dz, dz=dx) + assert p1.geometry.num_x_stab == p2.geometry.num_z_stab + assert p1.geometry.num_z_stab == p2.geometry.num_x_stab + + +# ============================================================ +# Circuit generation +# ============================================================ + + +@pytest.mark.parametrize( + ("dx", "dz"), + [ + (1, 1), + (1, 3), + (3, 1), + (2, 2), + (2, 3), + (3, 2), + (3, 3), + ], +) +def test_circuit_generation(dx, dz): + """LogicalCircuitBuilder should produce a valid TickCircuit for all dimensions.""" + from pecos.qec.surface import LogicalCircuitBuilder + + patch = SurfacePatch.create(dx=dx, dz=dz) + lcb = LogicalCircuitBuilder() + lcb.add_patch(patch, "A") + basis = "Z" if dx >= dz else "X" + lcb.add_memory("A", rounds=2, basis=basis) + tc = lcb.to_tick_circuit() + + assert tc.num_ticks() > 0 + assert tc.gate_count() > 0 + + +# ============================================================ +# Transversal gate square check +# ============================================================ + + +def test_transversal_h_rejects_nonsquare(): + """Transversal H should reject non-square patches.""" + from pecos.qec.surface import LogicalCircuitBuilder + + patch = SurfacePatch.create(dx=2, dz=3) + lcb = LogicalCircuitBuilder() + lcb.add_patch(patch, "A") + with pytest.raises(ValueError, match="square"): + lcb.add_transversal_h("A") + + +def test_transversal_h_accepts_square(): + """Transversal H should accept square patches of any distance.""" + from pecos.qec.surface import LogicalCircuitBuilder + + for d in [2, 3, 4, 5]: + patch = SurfacePatch.create(distance=d) + lcb = LogicalCircuitBuilder() + lcb.add_patch(patch, "A") + lcb.add_memory("A", rounds=1, basis="Z") + lcb.add_transversal_h("A") + lcb.add_memory("A", rounds=1, basis="X") + + +# ============================================================ +# Validation +# ============================================================ + + +def test_distance_zero_rejected(): + """Distance 0 should raise ValueError.""" + with pytest.raises(ValueError, match=r"Distance must be >= 1"): + SurfacePatch.create(distance=0) + + +def test_negative_distance_rejected(): + """Negative distance should raise ValueError.""" + with pytest.raises(ValueError, match=r"dx must be >= 1"): + SurfacePatch.create(dx=-1, dz=3) 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 2bc627c27..f5f7fa606 100644 --- a/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py +++ b/python/quantum-pecos/tests/qec/surface/test_surface_metadata.py @@ -54,8 +54,8 @@ def test_surface_schedule_helpers_expose_region_and_touch_labels() -> None: assert get_stabilizer_touch_label(x_bulk, patch, 4) == "BL" assert get_stabilizer_touch_label(x_bulk, patch, 5) == "BR" - assert get_stab_schedule("X", x_top.data_qubits, x_top.is_boundary, patch.distance) == [(2, 1), (3, 0)] - assert get_stab_schedule("Z", z_left.data_qubits, z_left.is_boundary, patch.distance) == [(0, 3), (1, 6)] + assert get_stab_schedule("X", x_top.data_qubits, x_top.is_boundary, patch.dx, patch.dz) == [(2, 1), (3, 0)] + assert get_stab_schedule("Z", z_left.data_qubits, z_left.is_boundary, patch.dx, patch.dz) == [(0, 3), (1, 6)] assert get_stabilizer_schedule_entries(x_top, patch) == [ {"round_0based": 2, "data_qubit": 1, "touch_label": "right"}, @@ -210,7 +210,7 @@ def test_tick_circuit_exposes_measurement_order() -> None: tick = tc.get_tick(tick_index) if tick is None: continue - for gate in tick.gates(): + for gate in tick.gate_batches(): if "MZ" not in str(gate.gate_type): continue for qubit in gate.qubits: diff --git a/python/quantum-pecos/tests/qec/test_analysis_meas_sampling.py b/python/quantum-pecos/tests/qec/test_analysis_meas_sampling.py new file mode 100644 index 000000000..800c1f717 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_analysis_meas_sampling.py @@ -0,0 +1,127 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Tests for analysis helpers with DEM-backed sampling backends.""" + +import pytest +from pecos.qec.analysis import empirical_correlation_table, fit_dem_from_simulation +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 + + +@pytest.fixture +def d3_circuit_and_noise(): + patch = SurfacePatch.create(distance=3) + tc = _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") + noise = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) + return tc, noise + + +class TestEmpiricalCorrelationTable: + def test_meas_sampling_returns_nonempty(self, d3_circuit_and_noise): + tc, noise = d3_circuit_and_noise + table = empirical_correlation_table( + tc, + noise, + shots=5000, + max_order=1, + backend="meas_sampling", + seed=42, + ) + assert len(table) > 0, "Should return at least one rate entry" + + def test_meas_sampling_label_shape(self, d3_circuit_and_noise): + tc, noise = d3_circuit_and_noise + table = empirical_correlation_table( + tc, + noise, + shots=5000, + max_order=1, + backend="meas_sampling", + seed=42, + ) + # Each entry is (detector_indices_tuple, probability) + for indices, prob in table: + assert isinstance(indices, tuple) + assert len(indices) >= 1 + assert isinstance(prob, float) + assert 0.0 <= prob <= 1.0 + + def test_meas_sampling_rates_close_to_stabilizer(self, d3_circuit_and_noise): + tc, noise = d3_circuit_and_noise + shots = 20000 + + meas_table = empirical_correlation_table( + tc, + noise, + shots=shots, + max_order=1, + backend="meas_sampling", + seed=42, + ) + stab_table = empirical_correlation_table( + tc, + noise, + shots=shots, + max_order=1, + backend="stabilizer", + seed=42, + ) + + # Both should have the same entries (same detectors) + meas_dict = dict(meas_table) + stab_dict = dict(stab_table) + + assert set(meas_dict.keys()) == set(stab_dict.keys()), "Same detector indices should appear in both" + + # Rates should be statistically close (within 20% relative for active detectors) + close_count = 0 + active_count = 0 + for key, d in meas_dict.items(): + s = stab_dict[key] + if s > 0.005: + active_count += 1 + if abs(d - s) / s < 0.20: + close_count += 1 + + assert active_count > 0, "Should have active detectors" + assert close_count >= active_count * 0.8, f"Only {close_count}/{active_count} rates within 20% of stabilizer" + + +class TestFitDemFromSimulation: + def test_meas_sampling_returns_dem_string(self, d3_circuit_and_noise): + tc, noise = d3_circuit_and_noise + dem_str = fit_dem_from_simulation( + tc, + noise, + shots=10000, + backend="meas_sampling", + seed=42, + ) + assert isinstance(dem_str, str) + assert "error(" in dem_str, "DEM string should contain error(...) lines" + + def test_meas_sampling_has_multiple_mechanisms(self, d3_circuit_and_noise): + tc, noise = d3_circuit_and_noise + dem_str = fit_dem_from_simulation( + tc, + noise, + shots=10000, + backend="meas_sampling", + seed=42, + ) + error_lines = [line for line in dem_str.strip().split("\n") if line.strip().startswith("error(")] + assert len(error_lines) > 10, f"Expected many mechanisms, got {len(error_lines)}" + + +class TestInvalidBackend: + def test_empirical_correlation_table_rejects_unknown(self, d3_circuit_and_noise): + tc, noise = d3_circuit_and_noise + with pytest.raises(ValueError, match=r"Unknown backend.*'bogus'"): + empirical_correlation_table(tc, noise, shots=10, backend="bogus") + + def test_fit_dem_from_simulation_rejects_unknown(self, d3_circuit_and_noise): + tc, noise = d3_circuit_and_noise + with pytest.raises(ValueError, match=r"Unknown backend.*'nope'"): + fit_dem_from_simulation(tc, noise, shots=10, backend="nope") 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 555249cf7..11f2e90ad 100644 --- a/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py +++ b/python/quantum-pecos/tests/qec/test_decomposed_dem_invariants.py @@ -55,7 +55,7 @@ def parse_dem_with_decomposed( def xor_targets(parts: list[tuple[tuple[int, ...], tuple[int, ...]]]) -> tuple[tuple[int, ...], tuple[int, ...]]: - """XOR a decomposed list of detector/logical targets into one combined effect.""" + """XOR a decomposed list of detector/DEM-output observables into one combined effect.""" dets: set[int] = set() logs: set[int] = set() for part_dets, part_logs in parts: @@ -97,10 +97,10 @@ def xor_lists(left: list[int], right: list[int]) -> list[int]: def xor_effect_rows(left: dict[str, list[int]], right: dict[str, list[int]]) -> tuple[list[int], list[int]]: - """XOR two structured detector/logical rows.""" + """XOR two structured detector/DEM-output rows.""" return ( xor_lists(left["detectors"], right["detectors"]), - xor_lists(left["logicals"], right["logicals"]), + xor_lists(left["dem_outputs"], right["dem_outputs"]), ) @@ -144,10 +144,10 @@ def build_source_tracked_dem(distance: int, basis: str, rounds: int = 20) -> obj dag = tc.to_dag_circuit() analyzer = DagFaultAnalyzer(dag) influence_map = analyzer.build_influence_map() - noise = NoiseModel(p1=0.01, p2=0.01, p_meas=0.01, p_init=0.01) + noise = NoiseModel(p1=0.01, p2=0.01, p_meas=0.01, p_prep=0.01) builder = DemBuilder(influence_map) - builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_init) + builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep) builder.with_num_measurements(int(tc.get_meta("num_measurements") or "0")) builder.with_measurement_order(get_measurement_order_from_tick_circuit(tc)) builder.with_detectors_json(tc.get_meta("detectors")) @@ -173,12 +173,12 @@ def test_dem_builder_accepts_public_surface_descriptor_json() -> None: tc = generate_tick_circuit_from_patch(patch, num_rounds=4, basis="X") dag = tc.to_dag_circuit() influence_map = DagFaultAnalyzer(dag).build_influence_map() - noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_init=0.001) + noise = NoiseModel(p1=0.001, p2=0.01, p_meas=0.01, p_prep=0.001) def _build(detectors_json: str, observables_json: str | None) -> object: """Build one source-tracked DEM from serialized detector metadata.""" builder = DemBuilder(influence_map) - builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_init) + builder.with_noise(noise.p1, noise.p2, noise.p_meas, noise.p_prep) builder.with_num_measurements(int(tc.get_meta("num_measurements") or "0")) builder.with_measurement_order(get_measurement_order_from_tick_circuit(tc)) builder.with_detectors_json(detectors_json) @@ -304,12 +304,12 @@ def test_native_decomposed_components_are_graphlike_and_map_back_to_full_dem(dis patch = SurfacePatch.create(distance=distance) tc = generate_tick_circuit_from_patch(patch, num_rounds=20, basis=basis) - params = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + params = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} full_dem = generate_dem_from_tick_circuit(tc, **params, decompose_errors=False) native_decomp_dem = generate_dem_from_tick_circuit(tc, **params, decompose_errors=True) - full_targets, _ = parse_dem_with_decomposed(full_dem) + fuldem_outputs, _ = parse_dem_with_decomposed(full_dem) _direct_targets, decomposed_targets = parse_dem_with_decomposed(native_decomp_dem) assert decomposed_targets, "expected representative circuit to contain decomposed terms" @@ -322,7 +322,7 @@ def test_native_decomposed_components_are_graphlike_and_map_back_to_full_dem(dis combined = xor_targets(parts) msg = f"decomposed components must XOR back to an effect present in the full DEM: {parts!r} -> {combined!r}" - assert combined in full_targets, msg + assert combined in fuldem_outputs, msg if combined[1]: saw_l0_decomposition = True @@ -341,7 +341,7 @@ def test_native_decomposed_matches_stim_singleton_l0_edges_for_representative_ci patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=20, basis=basis) - params = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + params = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} native_decomp_dem = generate_dem_from_tick_circuit(tc, **params, decompose_errors=True) stim_dem = generate_dem_from_tick_circuit_via_stim(tc, **params) @@ -364,7 +364,7 @@ def test_native_decomposed_preserves_all_stim_direct_observable_targets(distance patch = SurfacePatch.create(distance=distance) tc = generate_tick_circuit_from_patch(patch, num_rounds=20, basis=basis) - params = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + params = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} native_decomp_dem = generate_dem_from_tick_circuit(tc, **params, decompose_errors=True) stim_dem = generate_dem_from_tick_circuit_via_stim(tc, **params) @@ -392,7 +392,7 @@ def test_native_full_matches_stim_full_graph_summary_for_representative_circuit( patch = SurfacePatch.create(distance=distance) tc = generate_tick_circuit_from_patch(patch, num_rounds=20, basis=basis) - params = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + params = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} native_full_dem = generate_dem_from_tick_circuit(tc, **params, decompose_errors=False) stim_full_dem = generate_dem_from_tick_circuit_via_stim(tc, **params, decompose_errors=False) @@ -418,7 +418,7 @@ def test_generate_dem_from_tick_circuit_via_stim_can_skip_decomposition() -> Non p1=0.01, p2=0.01, p_meas=0.01, - p_init=0.01, + p_prep=0.01, decompose_errors=False, ) @@ -434,7 +434,7 @@ def test_structured_source_tracking_summaries_include_graphlike_decomposable_cou summaries = dem.contribution_effect_summaries() assert summaries - pair_summaries = [summary for summary in summaries if len(summary["detectors"]) == 2 and not summary["logicals"]] + pair_summaries = [summary for summary in summaries if len(summary["detectors"]) == 2 and not summary["dem_outputs"]] assert pair_summaries assert all("graphlike_decomposable_count" in summary for summary in pair_summaries) assert all(summary["graphlike_decomposable_count"] >= 0 for summary in pair_summaries) @@ -448,10 +448,10 @@ def test_structured_source_tracking_bindings_are_self_consistent(basis: str) -> summaries = dem.contribution_effect_summaries() assert summaries - observable_summary = next(row for row in summaries if row["logicals"]) + observable_summary = next(row for row in summaries if row["dem_outputs"]) contributions = dem.contributions_for_effect( observable_summary["detectors"], - observable_summary["logicals"], + observable_summary["dem_outputs"], ) assert contributions @@ -489,12 +489,12 @@ def test_structured_source_tracking_y_decomposed_rows_xor_back_to_effect(basis: assert summaries for summary in summaries[:20]: - contributions = dem.contributions_for_effect(summary["detectors"], summary["logicals"]) + 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_logicals"], row["z_logicals"]) == summary["logicals"] + assert xor_lists(row["x_dem_outputs"], row["z_dem_outputs"]) == summary["dem_outputs"] @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -504,7 +504,7 @@ def test_structured_direct_component_rows_xor_back_to_effect(basis: str) -> None rows = [] for summary in dem.contribution_effect_summaries(): - for row in dem.contributions_for_effect(summary["detectors"], summary["logicals"]): + for row in dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]): if row["source_type"] not in DIRECT_SOURCE_TYPES: continue if "component_1_detectors" not in row or "component_2_detectors" not in row: @@ -516,15 +516,15 @@ def test_structured_direct_component_rows_xor_back_to_effect(basis: str) -> None for summary, row in rows[:100]: left = { "detectors": row["component_1_detectors"], - "logicals": row["component_1_logicals"], + "dem_outputs": row["component_1_dem_outputs"], } right = { "detectors": row["component_2_detectors"], - "logicals": row["component_2_logicals"], + "dem_outputs": row["component_2_dem_outputs"], } dets, logs = xor_effect_rows(left, right) assert dets == summary["detectors"] - assert logs == summary["logicals"] + assert logs == summary["dem_outputs"] @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -534,7 +534,7 @@ def test_structured_one_sided_direct_component_rows_are_exposed(basis: str) -> N rows = [] for summary in dem.contribution_effect_summaries(): - for row in dem.contributions_for_effect(summary["detectors"], summary["logicals"]): + for row in dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]): if row["source_type"] != "DirectOneSidedComponent": continue rows.append((summary, row)) @@ -542,22 +542,22 @@ 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_logicals"]) - right_non_empty = bool(row["component_2_detectors"] or row["component_2_logicals"]) + 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"], - "logicals": row["component_1_logicals"], + "dem_outputs": row["component_1_dem_outputs"], }, { "detectors": row["component_2_detectors"], - "logicals": row["component_2_logicals"], + "dem_outputs": row["component_2_dem_outputs"], }, ) assert direct_dets == summary["detectors"] - assert direct_logs == summary["logicals"] + assert direct_logs == summary["dem_outputs"] @pytest.mark.parametrize("basis", ["X", "Z"]) @@ -569,7 +569,7 @@ def test_structured_direct_source_families_are_exposed_for_direct_rows(basis: st for summary in dem.contribution_effect_summaries(): rows.extend( row - for row in dem.contributions_for_effect(summary["detectors"], summary["logicals"]) + for row in dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]) if row["source_type"] in DIRECT_SOURCE_TYPES ) @@ -587,17 +587,17 @@ def test_structured_source_tracking_summaries_partition_all_contributions(basis: summaries = dem.contribution_effect_summaries() assert summaries - effect_keys = {(tuple(summary["detectors"]), tuple(summary["logicals"])) for summary in summaries} + effect_keys = {(tuple(summary["detectors"]), tuple(summary["dem_outputs"])) for summary in summaries} assert len(effect_keys) == len(summaries) total_count = 0 total_probability = 0.0 for summary in summaries: - contributions = dem.contributions_for_effect(summary["detectors"], summary["logicals"]) + contributions = dem.contributions_for_effect(summary["detectors"], summary["dem_outputs"]) total_count += len(contributions) total_probability += sum(float(row["probability"]) for row in contributions) assert all(row["detectors"] == summary["detectors"] for row in contributions) - assert all(row["logicals"] == summary["logicals"] for row in contributions) + assert all(row["dem_outputs"] == summary["dem_outputs"] for row in contributions) assert total_count == dem.num_contributions assert total_count == sum(int(summary["num_contributions"]) for summary in summaries) @@ -647,7 +647,7 @@ def test_structured_render_records_reproduce_render_summaries(basis: str) -> Non for row in render_records: key = ( tuple(row["detectors"]), - tuple(row["logicals"]), + tuple(row["dem_outputs"]), str(row["rendered_targets"]), ) bucket = regrouped.setdefault( @@ -692,7 +692,7 @@ def test_structured_render_records_reproduce_render_summaries(basis: str) -> Non for summary in render_summaries: key = ( tuple(summary["detectors"]), - tuple(summary["logicals"]), + tuple(summary["dem_outputs"]), str(summary["rendered_targets"]), ) bucket = regrouped[key] diff --git a/python/quantum-pecos/tests/qec/test_dem_equivalence.py b/python/quantum-pecos/tests/qec/test_dem_equivalence.py index 4fd21b4f8..073470eff 100644 --- a/python/quantum-pecos/tests/qec/test_dem_equivalence.py +++ b/python/quantum-pecos/tests/qec/test_dem_equivalence.py @@ -25,15 +25,18 @@ def test_parse_simple_mechanism(self) -> None: assert dem.num_mechanisms == 1 assert dem.num_detectors == 2 assert dem.num_observables == 0 + assert dem.num_tracked_paulis == 0 - def test_parse_mechanism_with_observable(self) -> None: - """Parse mechanism with observable.""" + def test_parse_mechanism_with_tracked_pauli(self) -> None: + """Parse mechanism with a Stim DEM output exposed as a tracked Pauli.""" dem_str = "error(0.02) D0 L0" dem = ParsedDem.from_string(dem_str) assert dem.num_mechanisms == 1 assert dem.num_detectors == 1 + assert dem.num_dem_outputs == 1 assert dem.num_observables == 1 + assert dem.num_tracked_paulis == 0 def test_parse_decomposed_mechanism(self) -> None: """Parse a decomposed mechanism (XOR chain).""" @@ -55,7 +58,9 @@ def test_parse_multiple_mechanisms(self) -> None: assert dem.num_mechanisms == 3 assert dem.num_detectors == 3 + assert dem.num_dem_outputs == 1 assert dem.num_observables == 1 + assert dem.num_tracked_paulis == 0 def test_parse_detector_declarations(self) -> None: """Parse detector declarations.""" @@ -337,7 +342,7 @@ def surface_code_dem(self) -> tuple[str, str]: patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=1, basis="Z") - noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} pecos_dem = generate_dem_from_tick_circuit(tc, **noise, decompose_errors=False) @@ -403,7 +408,7 @@ def surface_code_dem_pair(self) -> tuple[str, str]: patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="Z") - noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} raw_dem = generate_dem_from_tick_circuit(tc, **noise, decompose_errors=False) decomposed_dem = generate_dem_from_tick_circuit( @@ -523,7 +528,7 @@ def test_decomposition_equivalence_various_sizes( patch = SurfacePatch.create(distance=distance) tc = generate_tick_circuit_from_patch(patch, num_rounds=num_rounds, basis="Z") - noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} raw_dem_str = generate_dem_from_tick_circuit( tc, diff --git a/python/quantum-pecos/tests/qec/test_dem_probability_analysis.py b/python/quantum-pecos/tests/qec/test_dem_probability_analysis.py index b78296090..6be66cc3e 100644 --- a/python/quantum-pecos/tests/qec/test_dem_probability_analysis.py +++ b/python/quantum-pecos/tests/qec/test_dem_probability_analysis.py @@ -236,7 +236,7 @@ def test_dem_comparison_d3() -> None: p1 = 0.01 p2 = 0.01 p_meas = 0.01 - p_init = 0.01 + p_prep = 0.01 # Generate DEMs pecos_dem = generate_dem_from_tick_circuit( @@ -244,7 +244,7 @@ def test_dem_comparison_d3() -> None: p1=p1, p2=p2, p_meas=p_meas, - p_init=p_init, + p_prep=p_prep, decompose_errors=False, ) stim_dem = generate_dem_from_tick_circuit_via_stim( @@ -252,7 +252,7 @@ def test_dem_comparison_d3() -> None: p1=p1, p2=p2, p_meas=p_meas, - p_init=p_init, + p_prep=p_prep, ) print("\n--- PECOS DEM (raw, no decomposition) ---") @@ -417,14 +417,14 @@ def analyze_decomposition_pattern() -> None: patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=1, basis="Z") - p1, p2, p_meas, p_init = 0.01, 0.01, 0.01, 0.01 + p1, p2, p_meas, p_prep = 0.01, 0.01, 0.01, 0.01 stim_dem = generate_dem_from_tick_circuit_via_stim( tc, p1=p1, p2=p2, p_meas=p_meas, - p_init=p_init, + p_prep=p_prep, ) errors, decomposed = parse_stim_dem_with_decomposed(stim_dem) @@ -459,7 +459,7 @@ def analyze_decomposition_pattern() -> None: p1=p1, p2=p2, p_meas=p_meas, - p_init=p_init, + p_prep=p_prep, decompose_errors=False, ) pecos_errors = parse_dem(pecos_dem) diff --git a/python/quantum-pecos/tests/qec/test_dem_sampler.py b/python/quantum-pecos/tests/qec/test_dem_sampler.py index 65825a815..5728e7e4e 100644 --- a/python/quantum-pecos/tests/qec/test_dem_sampler.py +++ b/python/quantum-pecos/tests/qec/test_dem_sampler.py @@ -60,6 +60,10 @@ def test_dem_sampler_sampling() -> None: builder.with_observables_json('[{"id": 0, "records": [-1]}]') sampler = builder.build() + assert sampler.num_dem_outputs == 1 + assert sampler.num_observables == 1 + assert sampler.num_tracked_paulis == 0 + # Single sample det_events, obs_flips = sampler.sample(seed=42) assert isinstance(det_events, list) @@ -129,12 +133,239 @@ def test_dem_sampler_statistics() -> None: assert "logical_error_rate" in stats assert "syndrome_rate" in stats assert "undetectable_rate" in stats + assert "per_dem_output" in stats + assert "dem_output_rates" in stats + assert "observable_error_count" not in stats + assert "observable_error_rate" not in stats + assert "per_tracked_op" not in stats + assert "tracked_op_statistics_supported" not in stats + assert stats["per_dem_output"] == stats["per_observable"] + assert stats["dem_output_rates"] == stats["logical_rates"] + assert stats["tracked_pauli_statistics_supported"] is True + assert "tracked_pauli_statistics_error" not in stats + assert sampler.sample_tracked_paulis(seed=42) == [] + assert sampler.sample_tracked_pauli_batch(2, seed=42) == [[], []] assert stats["total_shots"] == 10000 assert 0.0 <= stats["logical_error_rate"] <= 1.0 assert 0.0 <= stats["syndrome_rate"] <= 1.0 +def test_dem_sampler_tracked_pauli_labels() -> None: + """Test sampler labels expose PECOS tracked-Pauli terminology.""" + from pecos_rslib import DagCircuit, PauliString + from pecos_rslib.qec import DemSampler + + dag = DagCircuit() + dag.pz([0]) + dag.h([0]) + dag.tracked_pauli(PauliString.from_str("X"), label="x_check") + + sampler = DemSampler.from_circuit(dag, p1=0.03, p2=0.0, p_meas=0.0, p_prep=0.0) + + labels = sampler.labels() + + assert sampler.num_tracked_paulis == 1 + assert sampler.num_dem_outputs == 0 + assert sampler.num_observables == 0 + assert "dem_outputs" in labels + assert "tracked_paulis" in labels + assert labels["dem_outputs"] == [] + assert labels["tracked_paulis"] == ["x_check"] + + stats = sampler.sample_statistics(2000, seed=7) + assert stats["logical_error_count"] == 0 + assert stats["per_observable"] == [] + assert stats["per_tracked_pauli"] == [] + assert stats["per_dem_output"] == [] + assert stats["tracked_pauli_statistics_supported"] is False + assert "cannot directly sample tracked Pauli flips" in stats["tracked_pauli_statistics_error"] + + with pytest.raises(RuntimeError, match="cannot directly sample tracked Pauli flips"): + sampler.sample_tracked_paulis(seed=7) + with pytest.raises(RuntimeError, match="cannot directly sample tracked Pauli flips"): + sampler.sample_tracked_pauli_batch(4, seed=7) + + +def test_detector_error_model_rejects_legacy_tracked_metadata_json() -> None: + """Python metadata import should fail fast on legacy tracked-op fields.""" + from pecos_rslib.qec import DetectorErrorModel + + old_json = """ + { + "format": "pecos.dem.metadata", + "version": 1, + "observables": [], + "tracked_paulis": [], + "tracked_ops": [ + { + "id": 0, + "kind": "tracked_op", + "label": "old_name", + "pauli": "+X0", + "records": [] + } + ] + } + """ + + with pytest.raises(ValueError, match="unsupported legacy metadata field: tracked_ops"): + DetectorErrorModel.from_pecos_metadata_json(old_json) + + +def test_dem_events_split_observables_and_tracked_paulis() -> None: + """DEM summaries report detector, observable, and tracked-Pauli effects separately.""" + from pecos_rslib import DagCircuit, PauliString + from pecos_rslib.qec import DetectorErrorModel + + dag = DagCircuit() + dag.pz([0]) + dag.h([0]) + dag.tracked_pauli(PauliString.from_str("X"), label="x_check") + dag.mz([0]) + dag.set_attr("num_measurements", "1") + dag.set_attr("observables", '[{"id": 0, "records": [-1]}]') + + dem = DetectorErrorModel.from_circuit( + dag, + p1=0.03, + p2=0.0, + p_meas=0.02, + p_prep=0.0, + ) + sampler = dem.to_sampler() + + assert dem.num_dem_outputs == 1 + assert dem.num_observables == 1 + assert dem.num_tracked_paulis == 1 + assert sampler.num_dem_outputs == 1 + assert sampler.num_observables == 1 + assert sampler.num_tracked_paulis == 1 + assert sampler.labels()["tracked_paulis"] == ["x_check"] + + summaries = dem.contribution_effect_summaries() + assert summaries + assert all("dem_outputs" in row for row in summaries) + assert all("observables" in row for row in summaries) + assert all("tracked_paulis" in row for row in summaries) + + observable_hits = {idx for row in summaries for idx in row["observables"]} + tracked_hits = {idx for row in summaries for idx in row["tracked_paulis"]} + assert 0 in observable_hits + assert tracked_hits == set() + + +def test_sample_decode_count_ignores_tracked_paulis() -> None: + """Decoder error counting uses observables, not tracked Paulis.""" + from pecos_rslib import DagCircuit, PauliString + from pecos_rslib.qec import DemSampler, DetectorErrorModel + + dag = DagCircuit() + dag.pz([0]) + dag.pz([1]) + dag.h([1]) + dag.tracked_pauli(PauliString.from_str("IZ"), label="tracked_z") + dag.mz([0]) + dag.set_attr("num_measurements", "1") + dag.set_attr("detectors", '[{"id": 0, "records": [-1]}]') + dag.set_attr("observables", '[{"id": 0, "records": [-1]}]') + + sampler = DemSampler.from_circuit( + dag, + p1=0.4, + p2=0.0, + p_meas=0.15, + p_prep=0.0, + ) + dem = DetectorErrorModel.from_circuit( + dag, + p1=0.4, + p2=0.0, + p_meas=0.15, + p_prep=0.0, + ) + + assert sampler.num_dem_outputs == 1 + assert sampler.num_observables == 1 + assert sampler.num_tracked_paulis == 1 + assert "logical_observable L0" in dem.to_string() + assert "logical_observable L1" not in dem.to_string() + + errors = sampler.sample_decode_count(dem.to_string(), 2000, seed=17) + assert errors == 0 + + +def test_influence_map_tracks_dem_outputs_and_tracked_paulis_separately() -> None: + """Test influence maps expose DEM outputs and filtered tracked Paulis.""" + from pecos_rslib import DagCircuit + from pecos_rslib.qec import InfluenceBuilder + + dag = DagCircuit() + dag.pz([0]) + dag.h([0]) + + builder = InfluenceBuilder(dag) + builder.with_tracked_pauli([(0, "X")]) + influence_map = builder.build() + + assert influence_map.num_tracked_paulis > 0 + assert influence_map.num_observables == 0 + assert influence_map.num_dem_outputs == 0 + + csr = influence_map.export_csr() + assert csr["num_dem_outputs"] == influence_map.num_dem_outputs + assert csr["num_internal_dem_outputs"] == influence_map.num_tracked_paulis + assert csr["num_observables"] == 0 + assert csr["num_tracked_paulis"] == influence_map.num_tracked_paulis + assert "dem_output_offsets_x" in csr + assert "dem_output_data_x" in csr + + for loc_idx in range(influence_map.num_locations): + tracked = influence_map.get_tracked_pauli_indices(loc_idx, 1) + dem_outputs = influence_map.get_dem_output_indices(loc_idx, 1) + internal_dem_outputs = influence_map.get_internal_dem_output_indices(loc_idx, 1) + assert dem_outputs == [] + assert tracked == internal_dem_outputs + assert not influence_map.has_dem_output_flips(loc_idx, 1) + assert influence_map.has_tracked_pauli_flips(loc_idx, 1) == bool(tracked) + + +def test_influence_builder_does_not_add_empty_tracked_paulis() -> None: + """An unconfigured Python InfluenceBuilder should not create identity tracked Paulis.""" + from pecos_rslib import DagCircuit + from pecos_rslib.qec import InfluenceBuilder + + dag = DagCircuit() + dag.pz([0]) + dag.h([0]) + dag.mz([0]) + + influence_map = InfluenceBuilder(dag).build() + + assert influence_map.num_dem_outputs == 0 + assert influence_map.num_observables == 0 + assert influence_map.num_tracked_paulis == 0 + + +def test_influence_builder_tracked_x_z_are_dem_outputs() -> None: + """Tracked X/Z helpers create tracked Paulis, not observables.""" + from pecos_rslib import DagCircuit + from pecos_rslib.qec import InfluenceBuilder + + dag = DagCircuit() + dag.pz([0]) + dag.h([0]) + + builder = InfluenceBuilder(dag) + builder.with_tracked_x([0]) + builder.with_tracked_z([0]) + influence_map = builder.build() + + assert influence_map.num_dem_outputs == 0 + assert influence_map.num_observables == 0 + assert influence_map.num_tracked_paulis == 2 + + def test_dem_sampler_zero_noise() -> None: """Test that zero noise produces no errors.""" from pecos_rslib import DagCircuit @@ -181,7 +412,8 @@ def test_dem_sampler_repr() -> None: repr_str = repr(sampler) assert "DemSampler" in repr_str assert "mechanisms" in repr_str - assert "detectors" in repr_str + assert "dem_outputs" in repr_str + assert "tracked_paulis" in repr_str if __name__ == "__main__": diff --git a/python/quantum-pecos/tests/qec/test_dem_sampler_modes.py b/python/quantum-pecos/tests/qec/test_dem_sampler_modes.py new file mode 100644 index 000000000..24a113e61 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_dem_sampler_modes.py @@ -0,0 +1,246 @@ +# 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. + +"""Tests for DemSampler Python bindings.""" + +from __future__ import annotations + +from typing import TYPE_CHECKING + +import pytest +from pecos.qec import DagFaultAnalyzer, DemSampler, DemSamplerBuilder +from pecos_rslib import DagCircuit + +if TYPE_CHECKING: + from pecos.qec import DagFaultInfluenceMap + + +def _build_repetition_code_circuit(num_rounds: int = 3) -> DagCircuit: + """Build a simple repetition code circuit.""" + dag = DagCircuit() + for _ in range(num_rounds): + dag.pz([3]) + dag.pz([4]) + dag.cx([(0, 3)]) + dag.cx([(1, 3)]) + dag.cx([(1, 4)]) + dag.cx([(2, 4)]) + dag.mz([3]) + dag.mz([4]) + return dag + + +def _build_influence_map(dag: DagCircuit) -> DagFaultInfluenceMap: + """Build influence map with logical Z.""" + analyzer = DagFaultAnalyzer(dag) + return analyzer.build_influence_map() + + +class TestDemSamplerRawMode: + """Test raw measurement output mode.""" + + def test_raw_uniform_creates_sampler(self) -> None: + """Test that raw_uniform constructor creates a valid sampler.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + sampler = DemSampler.raw_uniform(im, 0.01) + assert sampler.num_mechanisms > 0 + + def test_raw_circuit_noise_creates_sampler(self) -> None: + """Test that raw constructor with per-gate noise creates a valid sampler.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + sampler = DemSampler.raw(im, 0.001, 0.01, 0.005, 0.001) + assert sampler.num_mechanisms > 0 + + def test_raw_sample_returns_correct_shape(self) -> None: + """Test that raw sample returns non-empty output and observable lists.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + sampler = DemSampler.raw_uniform(im, 0.01) + outputs, obs = sampler.sample(seed=42) + assert isinstance(outputs, list) + assert isinstance(obs, list) + assert len(outputs) > 0 + + def test_raw_sample_batch(self) -> None: + """Test that sample_batch returns the requested number of shots.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + sampler = DemSampler.raw_uniform(im, 0.01) + all_outputs, _all_obs = sampler.sample_batch(100, seed=42) + assert len(all_outputs) == 100 + + def test_raw_zero_noise_statistics(self) -> None: + """Test that zero noise produces no syndromes or logical errors.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + sampler = DemSampler.raw_uniform(im, 0.0) + stats = sampler.sample_statistics(1000, seed=42) + assert stats["syndrome_count"] == 0 + assert stats["logical_error_count"] == 0 + + def test_raw_high_noise_produces_syndromes(self) -> None: + """Test that high noise produces a non-trivial syndrome rate.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + sampler = DemSampler.raw_uniform(im, 0.1) + stats = sampler.sample_statistics(5000, seed=42) + assert stats["syndrome_rate"] > 0.05 + + +class TestDemSamplerDetectorMode: + """Test detector-event output mode.""" + + def test_detector_mode_creates_sampler(self) -> None: + """Test that detector mode creates a sampler with correct output counts.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + sampler = DemSampler.with_detectors( + im, + detectors=[[-1], [-2]], + observables=[], + p1=0.001, + p2=0.01, + p_meas=0.005, + p_prep=0.001, + ) + assert sampler.num_outputs == 2 + assert sampler.num_observables == 0 + + def test_detector_mode_sample_shape(self) -> None: + """Test detector mode sample returns detector and observable counts.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + sampler = DemSampler.with_detectors( + im, + detectors=[[-1], [-2]], + observables=[[-1]], + p1=0.001, + p2=0.01, + p_meas=0.005, + p_prep=0.001, + ) + det_events, obs_flips = sampler.sample(seed=42) + assert len(det_events) == 2 + assert len(obs_flips) == 1 + assert sampler.num_dem_outputs == 1 + assert sampler.num_observables == 1 + assert sampler.num_tracked_paulis == 0 + + def test_detector_mode_matches_dem_sampler_builder(self) -> None: + """DemSampler detector mode should match DemSamplerBuilder exactly.""" + dag = _build_repetition_code_circuit(3) + im = _build_influence_map(dag) + + p1, p2, p_meas, p_prep = 0.001, 0.01, 0.005, 0.001 + det_records = [[-1], [-2]] + obs_records = [] + num_shots = 20_000 + seed = 42 + + # DemSamplerBuilder path + import json + + det_json = json.dumps([{"id": i, "records": r} for i, r in enumerate(det_records)]) + obs_json = json.dumps([{"id": i, "records": r} for i, r in enumerate(obs_records)]) + dem_sampler = ( + DemSamplerBuilder(im) + .with_noise(p1, p2, p_meas, p_prep) + .with_detectors_json(det_json) + .with_observables_json(obs_json) + .build() + ) + dem_stats = dem_sampler.sample_statistics(num_shots, seed) + + # DemSampler detector mode + unified_sampler = DemSampler.with_detectors( + im, + detectors=det_records, + observables=obs_records, + p1=p1, + p2=p2, + p_meas=p_meas, + p_prep=p_prep, + ) + unified_stats = unified_sampler.sample_statistics(num_shots, seed) + + # Should match exactly (same seed → same internal DemSamplerBuilder path) + assert dem_stats["syndrome_count"] == unified_stats["syndrome_count"] + assert dem_stats["logical_error_count"] == unified_stats["logical_error_count"] + + +class TestDemSamplerValidation: + """Test detector definition validation.""" + + def test_linearly_dependent_detectors_rejected(self) -> None: + """Test that linearly dependent detector definitions are rejected.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + + # D2 = D0 XOR D1 → linearly dependent + with pytest.raises(ValueError, match="linearly independent"): + DemSampler.with_detectors( + im, + detectors=[[0], [1], [0, 1]], + observables=[], + p1=0.001, + p2=0.01, + p_meas=0.005, + p_prep=0.001, + ) + + def test_independent_detectors_accepted(self) -> None: + """Test that linearly independent detector definitions are accepted.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + + sampler = DemSampler.with_detectors( + im, + detectors=[[0], [1]], + observables=[], + p1=0.001, + p2=0.01, + p_meas=0.005, + p_prep=0.001, + ) + assert sampler.num_outputs == 2 + + +class TestDemSamplerRepr: + """Test string representation.""" + + def test_repr_raw_mode(self) -> None: + """Test repr output for raw mode sampler.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + sampler = DemSampler.raw_uniform(im, 0.01) + r = repr(sampler) + assert "DemSampler" in r + assert "DemSampler" in r + + def test_repr_detector_mode(self) -> None: + """Test repr output for detector mode sampler.""" + dag = _build_repetition_code_circuit(2) + im = _build_influence_map(dag) + sampler = DemSampler.with_detectors( + im, + detectors=[[-1]], + observables=[], + p1=0.01, + p2=0.01, + p_meas=0.01, + p_prep=0.01, + ) + r = repr(sampler) + assert "DemSampler" in r + assert "DemSampler" in r 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 c99668f13..e21072a7f 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 @@ -39,7 +39,7 @@ def extract_measurement_order(tc: "TickCircuit") -> list[int]: tick = tc.get_tick(tick_idx) if tick is None: continue - for gate in tick.gates(): + for gate in tick.gate_batches(): gate_type = str(gate.gate_type) if "MZ" in gate_type: for qubit in gate.qubits: @@ -154,7 +154,7 @@ def surface_code_d3(self) -> tuple: @pytest.fixture def noise_params(self) -> dict[str, float]: """Standard noise parameters for testing.""" - return {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + return {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} def test_dem_mechanism_counts_match( self, @@ -278,19 +278,19 @@ def test_sampling_statistics_match_stim( # Stim sampling - returns (det_events, obs_flips, error_data) stim_det_events, stim_obs_flips, _ = stim_sampler.sample(num_shots) stim_syndrome_count = np.any(stim_det_events, axis=1).sum() - stim_logical_count = np.any(stim_obs_flips, axis=1).sum() + stim_observable_count = np.any(stim_obs_flips, axis=1).sum() # Compare rates pecos_syndrome_rate = pecos_stats["syndrome_rate"] stim_syndrome_rate = stim_syndrome_count / num_shots pecos_logical_rate = pecos_stats["logical_error_rate"] - stim_logical_rate = stim_logical_count / num_shots + stim_logical_rate = stim_observable_count / num_shots # Compare absolute differences - allow up to 10% relative difference # Known: PECOS and Stim have slightly different DEM generation syndrome_diff = abs(pecos_syndrome_rate - stim_syndrome_rate) - logical_diff = abs(pecos_logical_rate - stim_logical_rate) + observable_diff = abs(pecos_logical_rate - stim_logical_rate) # Syndrome rates should be within 20% relative max_rate = max(pecos_syndrome_rate, stim_syndrome_rate, 0.001) @@ -301,11 +301,11 @@ def test_sampling_statistics_match_stim( ) # Logical error rates should be within 30% relative (more variable) - max_logical = max(pecos_logical_rate, stim_logical_rate, 0.001) - logical_rel_diff = logical_diff / max_logical - assert logical_rel_diff < 0.5, ( + max_observable = max(pecos_logical_rate, stim_logical_rate, 0.001) + observable_rel_diff = observable_diff / max_observable + assert observable_rel_diff < 0.5, ( f"Logical error rate mismatch: PECOS={pecos_logical_rate:.4f}, " - f"Stim={stim_logical_rate:.4f}, rel_diff={logical_rel_diff:.1%}" + f"Stim={stim_logical_rate:.4f}, rel_diff={observable_rel_diff:.1%}" ) def test_detector_firing_rates_correlate( @@ -386,7 +386,7 @@ def test_multi_round_d3_r3(self) -> None: patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=3, basis="Z") - noise_params = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + noise_params = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} # Build PECOS sampler dag = tc.to_dag_circuit() @@ -423,7 +423,7 @@ def test_multi_round_d3_r3(self) -> None: max_rate = max(pecos_stats["logical_error_rate"], stim_logical_rate, 0.001) rel_diff = abs(pecos_stats["logical_error_rate"] - stim_logical_rate) / max_rate assert rel_diff < 0.5, ( - f"Logical rate mismatch for d=3, r=3: " + f"Observable rate mismatch for d=3, r=3: " f"PECOS={pecos_stats['logical_error_rate']:.4f}, Stim={stim_logical_rate:.4f}, " f"rel_diff={rel_diff:.1%}" ) @@ -441,7 +441,7 @@ def test_logical_error_rate_scales(self, distance: int) -> None: patch = SurfacePatch.create(distance=distance) tc = generate_tick_circuit_from_patch(patch, num_rounds=1, basis="Z") - noise_params = {"p1": 0.001, "p2": 0.001, "p_meas": 0.001, "p_init": 0.001} + noise_params = {"p1": 0.001, "p2": 0.001, "p_meas": 0.001, "p_prep": 0.001} # Build sampler dag = tc.to_dag_circuit() @@ -482,7 +482,7 @@ def test_x_basis_dem_matches_stim(self) -> None: patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=1, basis="X") - noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} # Generate DEMs (non-decomposed for exact comparison) pecos_dem = generate_dem_from_tick_circuit(tc, **noise, decompose_errors=False) @@ -531,7 +531,7 @@ def test_x_basis_sampling_matches_stim(self) -> None: patch = SurfacePatch.create(distance=3) tc = generate_tick_circuit_from_patch(patch, num_rounds=2, basis="X") - noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} # Build PECOS sampler dag = tc.to_dag_circuit() @@ -541,7 +541,9 @@ def test_x_basis_sampling_matches_stim(self) -> None: builder = DemSamplerBuilder(influence_map) builder.with_noise(**noise) builder.with_detectors_json(tc.get_meta("detectors") or "[]") - builder.with_observables_json(tc.get_meta("observables") or "[]") + builder.with_observables_json( + tc.get_meta("observables") or "[]", + ) builder.with_measurement_order(extract_measurement_order(tc)) pecos_sampler = builder.build() @@ -572,20 +574,20 @@ class TestAsymmetricNoise: @pytest.mark.parametrize( "noise_params", [ - {"p1": 0.001, "p2": 0.01, "p_meas": 0.005, "p_init": 0.002}, # p2 dominant - {"p1": 0.02, "p2": 0.001, "p_meas": 0.001, "p_init": 0.001}, # p1 dominant + {"p1": 0.001, "p2": 0.01, "p_meas": 0.005, "p_prep": 0.002}, # p2 dominant + {"p1": 0.02, "p2": 0.001, "p_meas": 0.001, "p_prep": 0.001}, # p1 dominant { "p1": 0.001, "p2": 0.001, "p_meas": 0.05, - "p_init": 0.001, + "p_prep": 0.001, }, # p_meas dominant { "p1": 0.001, "p2": 0.001, "p_meas": 0.001, - "p_init": 0.05, - }, # p_init dominant + "p_prep": 0.05, + }, # p_prep dominant ], ) def test_asymmetric_noise_dem_matches_stim( @@ -740,8 +742,8 @@ def _add_noise_to_stim( if name == "R": for t in targets: noisy.append("R", [t.value]) - if noise["p_init"] > 0: - noisy.append("X_ERROR", [t.value], noise["p_init"]) + if noise["p_prep"] > 0: + noisy.append("X_ERROR", [t.value], noise["p_prep"]) elif name in ("H", "S", "S_DAG"): for t in targets: noisy.append(name, [t.value]) @@ -823,7 +825,7 @@ def test_random_circuit_sampling_produces_valid_results(self, seed: int) -> None records = [-i for i in range(1, num_qubits + 1)] detectors_json = f'[{{"id": 0, "records": {records}}}]' - noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} builder = DemSamplerBuilder(influence_map) builder.with_noise(**noise) @@ -864,7 +866,7 @@ def test_dem_exact_match(self, distance: int, num_rounds: int, basis: str) -> No patch = SurfacePatch.create(distance=distance) tc = generate_tick_circuit_from_patch(patch, num_rounds=num_rounds, basis=basis) - noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_init": 0.01} + noise = {"p1": 0.01, "p2": 0.01, "p_meas": 0.01, "p_prep": 0.01} # Generate non-decomposed DEMs pecos_dem = generate_dem_from_tick_circuit(tc, **noise, decompose_errors=False) diff --git a/python/quantum-pecos/tests/qec/test_fault_catalog.py b/python/quantum-pecos/tests/qec/test_fault_catalog.py new file mode 100644 index 000000000..bd2950276 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_fault_catalog.py @@ -0,0 +1,515 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Tests for the fault_catalog() public API.""" + +import pytest +from pecos.quantum import PauliString, TickCircuit +from pecos_rslib_exp import ( + FaultAlternative, + FaultCatalog, + FaultLocation, + depolarizing, + fault_catalog, +) + + +def build_h_mz(): + """H(0) MZ(0): single-qubit depolarizing.""" + tc = TickCircuit() + tc.tick().h([0]) + tick = tc.tick() + tick.mz([0]) + tc.set_meta("num_measurements", "1") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + return tc + + +def build_cx_mz(): + """CX(0,1) MZ(0) MZ(1): two-qubit depolarizing.""" + tc = TickCircuit() + tc.tick().cx([(0, 1)]) + tick = tc.tick() + tick.mz([0]) + tick = tc.tick() + tick.mz([1]) + tc.set_meta("num_measurements", "2") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + return tc + + +def pauli_terms(pauli): + """Return a {qubit: label} map from a PECOS PauliString.""" + return {q: str(p).split(".")[-1] for p, q in pauli.get_paulis()} + + +class TestFaultCatalogStructure: + def test_returns_fault_catalog(self): + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + assert isinstance(catalog, FaultCatalog) + assert isinstance(catalog.locations, list) + assert len(catalog) > 0 + assert isinstance(catalog[0], FaultLocation) + assert catalog[0] is catalog.locations[0] + + def test_fault_catalog_is_sequence_like(self): + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + assert len(list(catalog)) == len(catalog.locations) + assert catalog[-1] is catalog.locations[-1] + + def test_location_attributes(self): + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + loc = catalog[0] + assert hasattr(loc, "tick") + assert hasattr(loc, "gate_index") + assert hasattr(loc, "gate_type") + assert hasattr(loc, "qubits") + assert hasattr(loc, "channel_probability") + assert hasattr(loc, "faults") + + def test_fault_alternative_attributes(self): + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + fault = catalog[0].faults[0] + assert isinstance(fault, FaultAlternative) + assert hasattr(fault, "kind") + assert hasattr(fault, "pauli") + assert hasattr(fault, "detectors") + assert hasattr(fault, "observables") + assert hasattr(fault, "tracked_paulis") + assert hasattr(fault, "measurements") + assert hasattr(fault, "conditional_probability") + assert hasattr(fault, "absolute_probability") + assert hasattr(fault, "channel_probability") + + +class TestPauliStringOutput: + def test_pauli_alternatives_are_pauli_string(self): + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + for loc in catalog: + for fault in loc.faults: + if fault.kind == "pauli": + assert isinstance(fault.pauli, PauliString), f"Expected PauliString, got {type(fault.pauli)}" + + def test_meas_prep_faults_have_none_pauli(self): + tc = TickCircuit() + tc.tick().pz([0]) + tick = tc.tick() + tick.mz([0]) + tc.set_meta("num_measurements", "1") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + + noise = depolarizing().p1(0).p2(0).p_meas(0.01).p_prep(0.01) + catalog = fault_catalog(tc, noise) + + for loc in catalog: + for fault in loc.faults: + if fault.kind in ("measurement_flip", "prep_flip"): + assert fault.pauli is None + + def test_two_qubit_pauli_has_two_terms(self): + tc = build_cx_mz() + noise = depolarizing().p1(0).p2(0.15).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + cx_loc = next(loc for loc in catalog if loc.gate_type == "CX") + # At least some alternatives should have two Pauli terms (XX, XY, etc.) + two_term = [f for f in cx_loc.faults if len(f.pauli.get_paulis()) == 2] + assert len(two_term) == 9, f"Expected 9 two-qubit Paulis, got {len(two_term)}" + + def test_new_gate_pauli_labels_and_measurement_effects(self): + tc = TickCircuit() + tc.tick().sx([0]) + tc.tick().szz([(0, 1)]) + tick = tc.tick() + tick.mz([0]) + tick.mz([1]) + tc.set_meta("num_measurements", "2") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + + noise = depolarizing().p1(0.03).p2(0.15).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + sx_loc = next(loc for loc in catalog if loc.gate_type == "SX") + assert [pauli_terms(f.pauli) for f in sx_loc.faults] == [ + {0: "X"}, + {0: "Y"}, + {0: "Z"}, + ] + assert any(f.measurements for f in sx_loc.faults) + + szz_loc = next(loc for loc in catalog if loc.gate_type == "SZZ") + assert len(szz_loc.faults) == 15 + observed = {(terms.get(0, "I"), terms.get(1, "I")) for terms in (pauli_terms(f.pauli) for f in szz_loc.faults)} + expected = { + ("X", "I"), + ("Y", "I"), + ("Z", "I"), + ("I", "X"), + ("I", "Y"), + ("I", "Z"), + ("X", "X"), + ("X", "Y"), + ("X", "Z"), + ("Y", "X"), + ("Y", "Y"), + ("Y", "Z"), + ("Z", "X"), + ("Z", "Y"), + ("Z", "Z"), + } + assert observed == expected + assert any(f.measurements for f in szz_loc.faults) + + +class TestNoEffectLocationsIncluded: + def test_no_downstream_measurement_location_included(self): + """A gate with p1>0 but no MZ after it still appears in the catalog.""" + tc = TickCircuit() + tc.tick().h([0]) # No MZ follows — no measurement effect + tc.set_meta("num_measurements", "0") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + + noise = depolarizing().p1(0.01).p2(0).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + h_locs = [loc for loc in catalog if loc.gate_type == "H"] + assert len(h_locs) == 1, "H with no downstream MZ should still appear" + assert len(h_locs[0].faults) == 3 + # All alternatives should have empty effects + for fault in h_locs[0].faults: + assert fault.measurements == [] + assert fault.detectors == [] + assert fault.observables == [] + assert abs(fault.absolute_probability - 0.01 / 3) < 1e-10 + + def test_prep_fault_with_no_effect_included(self): + """PZ followed by H then MZ: prep X → H → Z → no flip. Still in catalog.""" + tc = TickCircuit() + tc.tick().pz([0]) + tc.tick().h([0]) + tick = tc.tick() + tick.mz([0]) + tc.set_meta("num_measurements", "1") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + + noise = depolarizing().p1(0).p2(0).p_meas(0).p_prep(0.005) + catalog = fault_catalog(tc, noise) + + prep_locs = [loc for loc in catalog if any(f.kind == "prep_flip" for f in loc.faults)] + assert len(prep_locs) == 1 + fault = prep_locs[0].faults[0] + assert fault.kind == "prep_flip" + assert fault.pauli is None + # Prep X through H becomes Z which doesn't flip MZ → empty + assert fault.measurements == [] + + +class TestProbabilities: + def test_structural_catalog_without_noise(self): + tc = build_h_mz() + catalog = fault_catalog(tc) + + assert len(catalog.locations) == 2 + assert {loc.channel for loc in catalog.locations} == {"p1", "p_meas"} + assert all(loc.channel_probability == 0.0 for loc in catalog.locations) + assert all(loc.no_fault_probability == 1.0 for loc in catalog.locations) + assert all(fault.absolute_probability == 0.0 for loc in catalog.locations for fault in loc.faults) + + def test_with_noise_updates_existing_python_references(self): + tc = build_h_mz() + catalog = fault_catalog(tc) + h_loc = next(loc for loc in catalog if loc.channel == "p1") + h_fault = h_loc.faults[0] + mz_loc = next(loc for loc in catalog if loc.channel == "p_meas") + + catalog.with_noise(p1=0.06, p_meas=0.02) + + assert abs(h_loc.channel_probability - 0.06) < 1e-12 + assert abs(h_loc.no_fault_probability - 0.94) < 1e-12 + assert abs(h_fault.absolute_probability - 0.02) < 1e-12 + assert abs(h_fault.channel_probability - 0.06) < 1e-12 + assert abs(mz_loc.channel_probability - 0.02) < 1e-12 + assert abs(mz_loc.faults[0].absolute_probability - 0.02) < 1e-12 + + def test_parameterized_returns_independent_catalog(self): + tc = build_h_mz() + catalog = fault_catalog(tc, p1=0.03, p_meas=0.01) + clone = catalog.parameterized(p1=0.09, p_meas=0.04) + + original_h = next(loc for loc in catalog if loc.channel == "p1") + clone_h = next(loc for loc in clone if loc.channel == "p1") + assert abs(original_h.channel_probability - 0.03) < 1e-12 + assert abs(clone_h.channel_probability - 0.09) < 1e-12 + assert original_h is not clone_h + + def test_sparse_channel_keeps_zero_probability_structure(self): + tc = build_h_mz() + catalog = fault_catalog(tc, p1=0.0, p_meas=0.02) + + h_loc = next(loc for loc in catalog if loc.channel == "p1") + mz_loc = next(loc for loc in catalog if loc.channel == "p_meas") + assert h_loc.channel_probability == 0.0 + assert all(f.absolute_probability == 0.0 for f in h_loc.faults) + assert abs(mz_loc.channel_probability - 0.02) < 1e-12 + assert abs(mz_loc.faults[0].absolute_probability - 0.02) < 1e-12 + + def test_single_qubit_location_fields(self): + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + h_loc = next(loc for loc in catalog if loc.gate_type == "H") + assert h_loc.channel == "p1" + assert abs(h_loc.channel_probability - 0.03) < 1e-10 + assert abs(h_loc.no_fault_probability - 0.97) < 1e-10 + assert h_loc.num_alternatives == 3 + for fault in h_loc.faults: + assert abs(fault.conditional_probability - 1.0 / 3) < 1e-10 + assert abs(fault.absolute_probability - 0.01) < 1e-10 + + def test_two_qubit_location_fields(self): + tc = build_cx_mz() + noise = depolarizing().p1(0).p2(0.15).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + cx_loc = next(loc for loc in catalog if loc.gate_type == "CX") + assert cx_loc.channel == "p2" + assert abs(cx_loc.channel_probability - 0.15) < 1e-10 + assert abs(cx_loc.no_fault_probability - 0.85) < 1e-10 + assert cx_loc.num_alternatives == 15 + for fault in cx_loc.faults: + assert abs(fault.conditional_probability - 1.0 / 15) < 1e-10 + assert abs(fault.absolute_probability - 0.01) < 1e-10 + + def test_full_configuration_probability(self): + """Compute one full-circuit event probability from catalog fields.""" + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0.01).p_prep(0) + catalog = fault_catalog(tc, noise) + + # Pick first alternative at H location, no fault at MZ location + h_loc = next(loc for loc in catalog if loc.channel == "p1") + mz_loc = next(loc for loc in catalog if loc.channel == "p_meas") + + # P(alt 0 at H, no fault at MZ) = (p1/3) * (1 - p_meas) + config_prob = h_loc.faults[0].absolute_probability * mz_loc.no_fault_probability + expected = (0.03 / 3) * (1 - 0.01) # 0.01 * 0.99 = 0.0099 + assert abs(config_prob - expected) < 1e-10 + + +class TestDetectorObservableMapping: + def test_detectors_are_lists(self): + tc = TickCircuit() + tc.tick().h([0]) + tc.tick().cx([(0, 1)]) + tc.tick().h([0]) + tick = tc.tick() + tick.mz([0]) + tick = tc.tick() + tick.mz([1]) + tc.set_meta("num_measurements", "2") + tc.set_meta("detectors", '[{"records": [-2, -1]}]') + tc.set_meta("observables", "[]") + + noise = depolarizing().p1(0.01).p2(0).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + has_det = any(f.detectors for loc in catalog for f in loc.faults) + assert has_det, "Some faults should fire detectors" + + for loc in catalog: + for fault in loc.faults: + assert isinstance(fault.detectors, list) + assert isinstance(fault.observables, list) + + +class TestFaultConfigurations: + def test_k0_one_no_fault_event(self): + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0.01).p_prep(0) + catalog = fault_catalog(tc, noise) + + configs = list(catalog.fault_configurations(0)) + assert len(configs) == 1 + c = configs[0] + assert c.location_indices == [] + assert c.alternative_indices == [] + assert c.measurements == [] + assert c.detectors == [] + assert c.observables == [] + assert c.selected_probability == 1.0 + # config_prob = product of all no_fault_probability + expected = 1.0 + for loc in catalog.locations: + expected *= loc.no_fault_probability + assert abs(c.configuration_probability - expected) < 1e-12 + + def test_k1_exposes_single_fault(self): + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0.01).p_prep(0) + catalog = fault_catalog(tc, noise) + + configs = list(catalog.fault_configurations(1)) + # Total = sum of num_alternatives + expected_count = sum(loc.num_alternatives for loc in catalog.locations) + assert len(configs) == expected_count + + # First config: location 0, alternative 0 + c = configs[0] + assert c.location_indices == [0] + assert c.alternative_indices == [0] + assert c.selected_probability > 0 + + def test_k1_skips_zero_probability_structural_locations(self): + tc = build_h_mz() + catalog = fault_catalog(tc, p1=0.03, p_meas=0.0) + + assert len(catalog.locations) == 2 + h_idx = next(i for i, loc in enumerate(catalog) if loc.gate_type == "H") + mz_idx = next(i for i, loc in enumerate(catalog) if loc.gate_type == "MZ") + assert catalog.locations[mz_idx].channel_probability == 0.0 + + configs = list(catalog.fault_configurations(1)) + assert len(configs) == 3 + assert all(c.location_indices == [h_idx] for c in configs) + assert all(c.selected_probability > 0 for c in configs) + assert all(mz_idx not in c.location_indices for c in configs) + assert list(catalog.fault_configurations(2)) == [] + + def test_all_zero_noise_only_yields_k0(self): + tc = build_h_mz() + catalog = fault_catalog(tc, p1=0.0, p_meas=0.0) + + k0 = list(catalog.fault_configurations(0)) + assert len(k0) == 1 + assert k0[0].configuration_probability == 1.0 + assert list(catalog.fault_configurations(1)) == [] + + def test_nonzero_silent_faults_are_yielded(self): + tc = TickCircuit() + tc.tick().h([0]) + tc.set_meta("num_measurements", "0") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + + catalog = fault_catalog(tc, p1=0.03, p_meas=0.0) + configs = list(catalog.fault_configurations(1)) + + assert len(configs) == 3 + assert all(c.measurements == [] for c in configs) + assert all(c.detectors == [] for c in configs) + assert all(c.observables == [] for c in configs) + assert all(c.selected_probability > 0 for c in configs) + + def test_with_noise_zeroes_channel_for_new_iterators(self): + tc = build_h_mz() + catalog = fault_catalog(tc, p1=0.03, p_meas=0.01) + + catalog.with_noise(p1=0.0, p_meas=0.02) + + mz_idx = next(i for i, loc in enumerate(catalog) if loc.gate_type == "MZ") + configs = list(catalog.fault_configurations(1)) + assert len(configs) == 1 + assert configs[0].location_indices == [mz_idx] + assert configs[0].selected_probability == pytest.approx(0.02) + + def test_k2_xor_cancels_effects(self): + """Two faults flipping the same detector XOR-cancel.""" + tc = TickCircuit() + tc.tick().h([0]) + tc.tick().h([0]) + tick = tc.tick() + tick.mz([0]) + tc.set_meta("num_measurements", "1") + tc.set_meta("detectors", '[{"records":[-1]}]') + tc.set_meta("observables", "[]") + + noise = depolarizing().p1(0.03).p2(0).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + configs = list(catalog.fault_configurations(2)) + # Some configs should have empty detectors (XOR cancel) + cancelled = [c for c in configs if c.detectors == []] + assert len(cancelled) > 0 + + def test_k2_probability_hand_calc(self): + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0.01).p_prep(0) + catalog = fault_catalog(tc, noise) + # 2 locations: H(3 alts, p=0.03) and MZ(1 alt, p=0.01) + # k=2: both fire. selected = (0.03/3) * (0.01/1) = 0.0001 + # config = 0.0001 (no unselected locations) + + configs = list(catalog.fault_configurations(2)) + assert len(configs) == 3 # 3 H alternatives x 1 MZ alternative + for c in configs: + assert abs(c.selected_probability - 0.0001) < 1e-12 + assert abs(c.configuration_probability - 0.0001) < 1e-12 + + def test_returns_lazy_iterator_not_list(self): + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0.01).p_prep(0) + catalog = fault_catalog(tc, noise) + + it = catalog.fault_configurations(1) + assert not isinstance(it, list), "Should be a lazy iterator, not a list" + assert hasattr(it, "__next__"), "Should have __next__" + first = next(it) + assert hasattr(first, "location_indices") + assert hasattr(first, "locations") + assert hasattr(first, "faults") + assert hasattr(first, "tracked_paulis") + + def test_yielded_locations_and_faults(self): + tc = build_h_mz() + noise = depolarizing().p1(0.03).p2(0).p_meas(0.01).p_prep(0) + catalog = fault_catalog(tc, noise) + + first = next(catalog.fault_configurations(1)) + # .locations should be the FaultLocation objects for selected indices + assert len(first.locations) == 1 + assert first.locations[0] is catalog.locations[first.location_indices[0]] + # .faults should be the FaultAlternative objects + assert len(first.faults) == 1 + loc = catalog.locations[first.location_indices[0]] + assert first.faults[0] is loc.faults[first.alternative_indices[0]] + + def test_tracked_paulis_are_distinct_from_observables(self): + tc = TickCircuit() + tc.tick().h([0]) + tc.tracked_pauli(PauliString.from_str("Z"), label="tracked_z") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + + noise = depolarizing().p1(0.03).p2(0).p_meas(0).p_prep(0) + catalog = fault_catalog(tc, noise) + + h_loc = next(loc for loc in catalog if loc.gate_type == "H") + tracked = [fault.tracked_paulis for fault in h_loc.faults] + assert tracked.count([0]) == 2 + assert tracked.count([]) == 1 + assert all(fault.observables == [] for fault in h_loc.faults) + + configs = list(catalog.fault_configurations(1)) + assert any(c.tracked_paulis == [0] and c.observables == [] for c in configs) 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 new file mode 100644 index 000000000..242cbdcbb --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_inline_channel_sim_neo.py @@ -0,0 +1,74 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Inline TickCircuit channel tests for sim_neo Python bindings.""" + +import pytest +from pecos_rslib.quantum import TickCircuit +from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer + + +def prep_measure_circuit() -> TickCircuit: + tc = TickCircuit() + tc.tick().pz([0]) + tc.tick().mz([0]) + return tc + + +def measurement_rows(result) -> list[list[int]]: + return [list(row) for row in result.to_list()] + + +def test_tick_circuit_with_noise_inserts_channel_payload() -> None: + noisy = prep_measure_circuit().with_noise(p_prep=1.0) + channel_gates = [gate for _, gate in noisy.gate_batches() if gate.is_channel()] + + assert len(channel_gates) == 1 + assert channel_gates[0].channel_mixed_pauli_terms() == [ + (0.0, []), + (1.0, [("X", 0)]), + ] + + +def test_tick_circuit_with_noise_rejects_measurement_readout_noise() -> None: + with pytest.raises(ValueError, match="measurement readout noise"): + prep_measure_circuit().with_noise(p_meas=0.1) + + +def test_tick_circuit_with_noise_rejects_invalid_probabilities() -> None: + with pytest.raises(ValueError, match="p1 must be in \\[0, 1\\]"): + prep_measure_circuit().with_noise(p1=-0.1) + + with pytest.raises(ValueError, match="p2 must be in \\[0, 1\\]"): + prep_measure_circuit().with_noise(p2=1.1) + + +def test_sim_neo_default_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() + + assert measurement_rows(result) == [[1], [1], [1], [1], [1]] + + +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() + + assert measurement_rows(result) == [[1], [1], [1], [1], [1]] + + +def test_sim_neo_rejects_noise_builder_with_inline_channels() -> None: + noisy = prep_measure_circuit().with_noise(p_prep=1.0) + noise = depolarizing().p1(0.1) + + with pytest.raises(ValueError, match="do not also pass"): + sim_neo(noisy).noise(noise).run() + + +def test_sim_neo_meas_sampling_rejects_inline_channels() -> None: + noisy = prep_measure_circuit().with_noise(p_prep=1.0) + + with pytest.raises(ValueError, match="does not consume inline channel"): + sim_neo(noisy).quantum(meas_sampling()).run() diff --git a/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py new file mode 100644 index 000000000..a0564c682 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_meas_sampling_backend.py @@ -0,0 +1,177 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Integration tests for meas_sampling() sim_neo backend. + +Tests the d=3 surface code 57/48 regression and method dispatch. +""" + +import json + +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 + + +@pytest.fixture +def d3_tc(): + patch = SurfacePatch.create(distance=3) + return _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") + + +@pytest.fixture +def depol(): + return depolarizing().p1(0.0005).p2(0.005).p_meas(0.005).p_prep(0.005) + + +@pytest.fixture +def coherent(): + return depolarizing().p1(0.0005).p2(0.005).p_meas(0.005).p_prep(0.005).idle_rz(0.05) + + +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 + + 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() + 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() + mean_4 = sum(s[4] for s in r) / shots + assert mean_4 < 0.1, f"meas[4]={mean_4:.3f}" + + def test_z_type_detection_rates_match_stabilizer(self, d3_tc, depol): + """Z-type detector rates match stabilizer (known-good subset).""" + shots = 10000 + det_json = json.loads(d3_tc.get_meta("detectors")) + num_meas = int(d3_tc.get_meta("num_measurements")) + num_dets = len(det_json) + + def rates(results): + r = [0.0] * num_dets + for shot in results: + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(shot): + val ^= shot[idx] + if val: + 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_rates = rates(meas_r) + stab_rates = rates(stab_r) + + # Z-type detectors (deterministic measurements) should match. + close_count = sum( + 1 for d, s in zip(meas_rates, stab_rates, strict=False) if s > 0.001 and abs(d - s) / s < 0.15 + ) + total_active = sum(1 for s in stab_rates if s > 0.001) + + # At least half the detectors should match (Z-type ones) + assert ( + close_count >= total_active // 2 + ), f"Only {close_count}/{total_active} detectors within 15% of stabilizer." + + def test_all_detection_rates_match_stabilizer(self, d3_tc, depol): + """ALL detector rates should match stabilizer (target correctness).""" + shots = 20000 + det_json = json.loads(d3_tc.get_meta("detectors")) + num_meas = int(d3_tc.get_meta("num_measurements")) + num_dets = len(det_json) + + def rates(results): + r = [0.0] * num_dets + for shot in results: + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(shot): + val ^= shot[idx] + if val: + 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_rates = rates(meas_r) + stab_rates = rates(stab_r) + + max_diff = max(abs(d - s) / max(s, 1e-10) for d, s in zip(meas_rates, stab_rates, strict=False) if s > 0.001) + assert max_diff < 0.15, f"Max relative det rate diff: {max_diff:.1%}" + + def test_observable_flip_rates_match_stabilizer(self, d3_tc): + """Observable record extraction should match the stabilizer backend.""" + shots = 5000 + noise = depolarizing().p1(0.001).p2(0.01).p_meas(0.01).p_prep(0.01) + obs_json = json.loads(d3_tc.get_meta("observables") or "[]") + num_meas = int(d3_tc.get_meta("num_measurements")) + assert obs_json, "surface-code memory circuit should define observables" + + def rates(results): + r = [0.0] * len(obs_json) + for shot in results: + for i, obs in enumerate(obs_json): + val = 0 + for rec in obs["records"]: + idx = num_meas + rec + if 0 <= idx < len(shot): + val ^= shot[idx] + if val: + 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_rates = rates(meas_r) + stab_rates = rates(stab_r) + for i, (meas_rate, stab_rate) in enumerate(zip(meas_rates, stab_rates, strict=False)): + abs_diff = abs(meas_rate - stab_rate) + rel_diff = abs_diff / max(stab_rate, 1e-12) + assert ( + abs_diff < 0.03 or rel_diff < 0.5 + ), f"Observable L{i} rate mismatch: meas_sampling={meas_rate:.4f}, stabilizer={stab_rate:.4f}" + + +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 + + 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 + + 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() + + 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 + + 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 + + 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() + + 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() diff --git a/python/quantum-pecos/tests/qec/test_meas_sampling_generality.py b/python/quantum-pecos/tests/qec/test_meas_sampling_generality.py new file mode 100644 index 000000000..f8315c5f5 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_meas_sampling_generality.py @@ -0,0 +1,283 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Generality tests for meas_sampling() stochastic raw measurement backend. + +These test the core fault propagation and measurement sampling logic +on minimal hand-built circuits — not surface-code-specific. +""" + +import json + +import pytest +from pecos.quantum import TickCircuit +from pecos_rslib_exp import depolarizing, meas_sampling, sim_neo, stabilizer, statevec + + +def build_two_round_x_check(): + """Minimal 2-round X-check: ancilla q0, data q1 q2.""" + tc = TickCircuit() + # Round 1 + tc.tick().h([0]) + tc.tick().cx([(0, 1)]) + tc.tick().cx([(0, 2)]) + tc.tick().h([0]) + tick = tc.tick() + tick.mz([0]) + tc.tick().pz([0]) + # Round 2 + tc.tick().h([0]) + tc.tick().cx([(0, 1)]) + tc.tick().cx([(0, 2)]) + tc.tick().h([0]) + tick = tc.tick() + tick.mz([0]) + + # Detector: m0 XOR m1 should be 0 in noiseless case + tc.set_meta("num_measurements", "2") + tc.set_meta("detectors", json.dumps([{"records": [-1, -2]}])) + tc.set_meta("observables", "[]") + return tc + + +def build_three_round_z_check(): + """3-round Z-check on single data qubit: ancilla q0, data q1.""" + tc = TickCircuit() + for _ in range(3): + tc.tick().pz([0]) + tc.tick().cx([(1, 0)]) # data is control for Z-check + tick = tc.tick() + tick.mz([0]) + + tc.set_meta("num_measurements", "3") + tc.set_meta( + "detectors", + json.dumps( + [ + {"records": [-2, -3]}, # m0 XOR m1 + {"records": [-1, -2]}, # m1 XOR m2 + ], + ), + ) + tc.set_meta("observables", "[]") + return tc + + +class TestMeasurementFaultIndependence: + """Measurement faults must not cancel through Copy chains.""" + + def test_two_round_meas_fault_both_fire(self): + """A detector comparing two Copy-linked measurements should see + faults from BOTH measurements independently. + """ + tc = build_two_round_x_check() + # Measurement-only noise: each meas flips with p=0.01 + 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() + + # Extract detector rate + def det_rate(results): + return sum(s[0] ^ s[1] for s in results) / len(results) + + meas_rate = det_rate(meas_r) + stab_rate = det_rate(stab_r) + + # Expected: ~2*p_meas = 0.02 (two independent flips) + assert ( + abs(meas_rate - stab_rate) / max(stab_rate, 1e-10) < 0.15 + ), f"Meas fault rate mismatch: dem={meas_rate:.4f} stab={stab_rate:.4f}" + + +class TestPrepFaultAbsorption: + """Prep faults propagate forward but get absorbed at PZ/MZ.""" + + def test_prep_fault_reaches_next_measurement(self): + """A prep fault on PZ(ancilla) should flip the next ancilla MZ.""" + tc = build_two_round_x_check() + # Prep-only noise + 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() + + def det_rate(results): + return sum(s[0] ^ s[1] for s in results) / len(results) + + meas_rate = det_rate(meas_r) + stab_rate = det_rate(stab_r) + + # Prep faults fire the detector (X error → detected at MZ) + assert stab_rate > 0.005, f"Stabilizer should see prep faults: {stab_rate}" + assert ( + abs(meas_rate - stab_rate) / stab_rate < 0.15 + ), f"Prep fault rate mismatch: dem={meas_rate:.4f} stab={stab_rate:.4f}" + + def test_prep_fault_does_not_cross_reset(self): + """A prep fault should NOT propagate past a subsequent PZ on the same qubit.""" + tc = build_three_round_z_check() + 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() + + # Extract detector 0 (m0 XOR m1) rate + def det_rate(results, d): + num_meas = 3 + recs = [{"records": [-2, -3]}, {"records": [-1, -2]}][d]["records"] + fired = sum(1 for s in results if sum(s[num_meas + r] for r in recs) % 2 == 1) + return fired / len(results) + + for d in [0, 1]: + meas_rate = det_rate(meas_r, d) + stab_rate = det_rate(stab_r, d) + assert ( + abs(meas_rate - stab_rate) / max(stab_rate, 1e-10) < 0.20 + ), f"Det {d} prep fault mismatch: dem={meas_rate:.4f} stab={stab_rate:.4f}" + + +class TestMultiRoundNonSurface: + """Multi-round circuits that are NOT surface codes.""" + + def test_three_round_z_check_all_noise(self): + """3-round Z-check with full depolarizing noise matches stabilizer.""" + tc = build_three_round_z_check() + 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() + + def det_rate(results, d): + num_meas = 3 + recs = [{"records": [-2, -3]}, {"records": [-1, -2]}][d]["records"] + fired = sum(1 for s in results if sum(s[num_meas + r] for r in recs) % 2 == 1) + return fired / len(results) + + for d in [0, 1]: + meas_rate = det_rate(meas_r, d) + stab_rate = det_rate(stab_r, d) + assert ( + abs(meas_rate - stab_rate) / max(stab_rate, 1e-10) < 0.15 + ), f"Det {d} mismatch: dem={meas_rate:.4f} stab={stab_rate:.4f}" + + +class TestZeroNoise: + """With zero noise, all detectors must fire at rate 0.""" + + 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() + 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() + for d in [0, 1]: + num_meas = 3 + recs = [{"records": [-2, -3]}, {"records": [-1, -2]}][d]["records"] + fired = sum(1 for s in r if sum(s[num_meas + r_] for r_ in recs) % 2 == 1) + assert fired == 0, f"Zero-noise det {d} fired {fired}/1000" + + def test_new_clifford_gates_match_stabilizer_zero_noise(self): + """meas_sampling and stabilizer agree exactly on a noiseless new-gate circuit.""" + tc = TickCircuit() + tc.tick().pz([0, 1, 2]) + tc.tick().x([0]) + tc.tick().cy([(0, 1)]) + tc.tick().szz([(1, 2)]) + tc.tick().swap([(1, 2)]) + tc.tick().sx([2]) + tc.tick().sxdg([2]) + tick = tc.tick() + tick.mz([0]) + tick.mz([1]) + tick.mz([2]) + tc.set_meta("num_measurements", "3") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + + 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() + + assert len(meas_r) == len(stab_r) == shots + for shot in range(shots): + assert list(meas_r[shot]) == list(stab_r[shot]) == [1, 0, 1] + + +class TestCYGateSupport: + """CY gate should work through the meas_sampling public path.""" + + def test_cy_sign_has_circuit_level_measurement_effect(self): + """CY maps XX to -YZ, so measuring YZ after CY gives odd parity.""" + tc = TickCircuit() + tc.tick().pz([0, 1]) + tc.tick().h([0]) + tc.tick().h([1]) + tc.tick().cy([(0, 1)]) + tc.tick().f([0]) + tick = tc.tick() + tick.mz([0]) + tick.mz([1]) + tc.set_meta("num_measurements", "2") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + + 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() + for shot in range(result.num_shots): + row = list(result[shot]) + assert row[0] ^ row[1] == 1 + + def test_cy_circuit_shape_and_values(self): + """H(0) CY(0,1) MZ(0) MZ(1): 2 measurements, no unsupported-gate error.""" + tc = TickCircuit() + tc.tick().h([0]) + tc.tick().cy([(0, 1)]) + tick = tc.tick() + tick.mz([0]) + tick = tc.tick() + tick.mz([1]) + tc.set_meta("num_measurements", "2") + tc.set_meta("detectors", "[]") + 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() + + assert len(result) == 100 + assert len(result[0]) == 2 + for shot in range(100): + for meas in range(2): + assert result.get(shot, meas) in (0, 1) + + def test_cy_matches_stabilizer_protocol(self): + """CY circuit: meas_sampling and stabilizer produce same output shape.""" + tc = TickCircuit() + tc.tick().h([0]) + tc.tick().cy([(0, 1)]) + tick = tc.tick() + tick.mz([0]) + tick = tc.tick() + tick.mz([1]) + tc.set_meta("num_measurements", "2") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + + 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() + + 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_parsed_dem_sampler.py b/python/quantum-pecos/tests/qec/test_parsed_dem_sampler.py index b21f4db0c..11537c084 100644 --- a/python/quantum-pecos/tests/qec/test_parsed_dem_sampler.py +++ b/python/quantum-pecos/tests/qec/test_parsed_dem_sampler.py @@ -146,6 +146,33 @@ def test_optimized_sampler_creation(self) -> None: assert sampler.num_mechanisms == 1 assert sampler.num_detectors == 2 + def test_optimized_sampler_projects_tracked_paulis_but_fails_direct_sampling(self) -> None: + """Parsed PECOS DEM samplers preserve tracked-Pauli IDs but do not sample them directly.""" + from pecos_rslib.qec import ParsedDem + + parsed = ParsedDem.from_string("error(0.1) D0 TP1") + sampler = parsed.to_dem_sampler() + + assert parsed.num_tracked_paulis == 2 + assert sampler.num_detectors == 1 + assert sampler.num_dem_outputs == 0 + assert sampler.num_tracked_paulis == 2 + + detectors, dem_outputs = sampler.sample(seed=11) + assert len(detectors) == 1 + assert isinstance(detectors[0], bool) + assert dem_outputs == [] + + with pytest.raises(RuntimeError, match="cannot directly sample tracked Pauli flips"): + sampler.sample_tracked_paulis(seed=11) + + def test_parser_rejects_legacy_tracked_metadata_extension(self) -> None: + """The PECOS DEM parser should not accept old tracked-op extension lines.""" + from pecos_rslib.qec import ParsedDem + + with pytest.raises(ValueError, match="unsupported PECOS DEM extension line"): + ParsedDem.from_string('pecos_tracked_op {"id":0,"pauli":"+X0"}') + def test_optimized_matches_naive_sampler(self) -> None: """Optimized sampler should produce same statistics as naive sampler.""" from pecos_rslib.qec import ParsedDem diff --git a/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py new file mode 100644 index 000000000..aceeb3050 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_qec_ux_entrypoints.py @@ -0,0 +1,146 @@ +"""User-facing QEC entry point and metadata ergonomics tests.""" + +from __future__ import annotations + +import json +from pathlib import Path + +import pytest + + +def test_sim_neo_stack_runs_from_exp() -> None: + pecos_rslib_exp = pytest.importorskip("pecos_rslib_exp") + from pecos.quantum import TickCircuit + + tc = TickCircuit() + tc.tick().mz([0]) + + result = ( + pecos_rslib_exp.sim_neo(tc) + .quantum(pecos_rslib_exp.stabilizer()) + .noise(pecos_rslib_exp.depolarizing()) + .shots(2) + .seed(123) + .run() + ) + assert result.num_shots == 2 + assert pecos_rslib_exp.meas_sampling() is not None + assert callable(pecos_rslib_exp.fault_catalog) + + +def test_build_memory_circuit_is_public_surface_helper() -> None: + from pecos.qec.surface import build_memory_circuit + + tc = build_memory_circuit(distance=3, rounds=2, basis="Z") + + assert int(tc.get_meta("num_measurements")) > 0 + assert json.loads(tc.get_meta("detectors")) + assert json.loads(tc.get_meta("observables")) + + +def test_surface_code_memory_runs_native_zero_noise_quick_start() -> 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, + ) + + assert result.distance == 3 + assert result.num_shots == 4 + assert result.num_rounds == 1 + 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 pecos.qec.surface import NoiseModel, surface_code_memory + + with pytest.raises(ValueError, match="either physical_error_rate or noise_model"): + surface_code_memory( + physical_error_rate=0.0, + noise_model=NoiseModel.uniform(0.001), + shots=0, + rounds=1, + ) + + +def test_tick_circuit_metadata_helpers_build_detector_and_observable_json() -> None: + from pecos.quantum import TickCircuit + + tc = TickCircuit() + det_id = tc.add_detector(records=[-1], coords=[0.0, 1.0, 2.0], label="d0") + obs_id = tc.add_observable(records=[-1, -2], label="L2") + + detectors = json.loads(tc.get_meta("detectors")) + observables = json.loads(tc.get_meta("observables")) + + assert det_id == 0 + assert detectors == [{"id": 0, "records": [-1], "coords": [0.0, 1.0, 2.0], "label": "d0"}] + assert int(tc.get_meta("num_detectors")) == 1 + + assert obs_id == 2 + assert observables == [{"id": 2, "records": [-1, -2], "label": "L2"}] + assert int(tc.get_meta("num_observables")) == 3 + + +def test_tracked_pauli_public_api_uses_current_names_only() -> None: + from pecos.quantum import DagCircuit, GateRegistry, GateType, TickCircuit, X + + assert GateType.TrackedPauliMeta.name == "TrackedPauli" + assert repr(GateType.TrackedPauliMeta) == "GateType.TrackedPauli" + + stub_text = (Path(__file__).parents[3] / "pecos-rslib" / "pecos_rslib.pyi").read_text() + assert "TrackedPauliMeta: GateType" in stub_text + assert "TrackedOperator" not in stub_text + + for circuit in (DagCircuit(), TickCircuit()): + assert hasattr(circuit, "tracked_pauli") + assert not hasattr(circuit, "tracked_operator") + assert not hasattr(circuit, "tracked_op") + + idx = circuit.tracked_pauli(X(0), label="x_probe") + assert idx == 0 + assert circuit.annotations()[0]["kind"] == "tracked_pauli" + assert circuit.annotations()[0]["label"] == "x_probe" + + for alias in ("TrackedPauli", "TrackedPauliMeta", "TP"): + registry = GateRegistry() + registry.define(f"Use{alias}", 1).step(alias, [0]).register_into(registry) + assert registry.decompose(f"Use{alias}", [7], []) == [ + ("TrackedPauli", [7], [], {}), + ] + + registry = GateRegistry() + with pytest.raises(ValueError, match="Unknown gate type"): + registry.define("Legacy", 1).step("TrackedOperator", [0]) + + +def test_tick_circuit_observable_helper_rejects_conflicting_label_id() -> None: + from pecos.quantum import TickCircuit + + tc = TickCircuit() + with pytest.raises(ValueError, match="conflicts"): + tc.add_observable(records=[-1], observable_id=1, label="L2") + + +def test_tick_circuit_reset_clears_annotations_and_measurement_records() -> None: + from pecos.quantum import TickCircuit, Z + + tc = TickCircuit() + measurements = tc.tick().mz([0]) + tc.detector(measurements) + tc.observable(measurements) + tc.tracked_pauli(Z(0)) + + assert tc.num_measurements() == 1 + assert len(tc.annotations()) == 3 + + tc.reset() + + assert tc.num_ticks() == 0 + assert tc.num_measurements() == 0 + assert tc.annotations() == [] diff --git a/python/quantum-pecos/tests/qec/test_raw_measurement_result.py b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py new file mode 100644 index 000000000..7bb169a1e --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_raw_measurement_result.py @@ -0,0 +1,190 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Tests proving RawMeasurementResult protocol compatibility across backends. + +All sim_neo backends now return RawMeasurementResult, a common Rust-backed +type that supports indexing, iteration, len(), and get(). This test verifies +the output contract is identical for stabilizer and meas_sampling. +""" + +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 + + +@pytest.fixture +def d3_results(): + """Run both backends on the same circuit and return their results.""" + 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) + + 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 + + +class TestCommonProtocol: + """Both backends return objects with the same interface.""" + + def test_len(self, 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 + # 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 + + def test_item_values(self, 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) + for val in meas_r[0]: + assert val in (0, 1) + + def test_list_conversion(self, d3_results): + stab_r, meas_r = 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 + + def test_iteration(self, d3_results): + stab_r, meas_r = d3_results + stab_count = 0 + for row in stab_r: + stab_count += 1 + assert len(row) == 57 + assert stab_count == 100 + + dem_count = 0 + for row in meas_r: + dem_count += 1 + assert len(row) == 57 + assert dem_count == 100 + + def test_out_of_range_raises_index_error(self, 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 + 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 + + def test_get_method(self, 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 + with pytest.raises(IndexError): + stab_r.get(100, 0) + with pytest.raises(IndexError): + meas_r.get(0, 57) + + def test_get_shot_method(self, d3_results): + stab_r, meas_r = d3_results + s = stab_r.get_shot(0) + d = meas_r.get_shot(0) + assert len(s) == len(d) == 57 + + def test_to_list(self, d3_results): + stab_r, meas_r = 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 + + def test_negative_index_raises_index_error(self, d3_results): + """Negative indexing raises IndexError, never OverflowError.""" + 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 + # Negative shot + with pytest.raises(IndexError): + stab_r.get(-1, 0) + with pytest.raises(IndexError): + meas_r.get(-1, 0) + # Negative measurement + with pytest.raises(IndexError): + stab_r.get(0, -1) + with pytest.raises(IndexError): + meas_r.get(0, -1) + + def test_get_shot_negative_raises_index_error(self, d3_results): + stab_r, meas_r = d3_results + with pytest.raises(IndexError): + stab_r.get_shot(-1) + with pytest.raises(IndexError): + meas_r.get_shot(-1) + + def test_out_of_range_uses_len(self, d3_results): + """result[len(result)] raises IndexError.""" + stab_r, meas_r = d3_results + with pytest.raises(IndexError): + stab_r[len(stab_r)] + with pytest.raises(IndexError): + meas_r[len(meas_r)] + + +class TestGenericConsumer: + """A generic helper that works unchanged for any backend.""" + + def compute_measurement_means(self, result): + """Compute per-measurement mean across all shots — works for any backend.""" + shots = len(result) + if shots == 0: + return [] + n_meas = len(result[0]) + means = [0.0] * n_meas + for row in result: + for i, val in enumerate(row): + means[i] += val + return [m / shots for m in means] + + 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() + + means = self.compute_measurement_means(result) + assert len(means) == 57 + # 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 + + 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() + + means = self.compute_measurement_means(result) + assert len(means) == 57 + nondet = sum(1 for m in means if abs(m - 0.5) < 0.15) + assert nondet > 0 diff --git a/python/quantum-pecos/tests/qec/test_sample_batch.py b/python/quantum-pecos/tests/qec/test_sample_batch.py new file mode 100644 index 000000000..4170da0ac --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_sample_batch.py @@ -0,0 +1,95 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Tests for SampleBatch columnar storage and validation.""" + +import pytest +from pecos_rslib.qec import DemSampler, SampleBatch + + +class TestSampleBatchConstruction: + def test_round_trip_get_syndrome(self): + batch = SampleBatch([[1, 0], [0, 1]], [1, 0]) + assert list(batch.get_syndrome(0)) == [1, 0] + assert list(batch.get_syndrome(1)) == [0, 1] + + def test_round_trip_get_observable_mask(self): + batch = SampleBatch([[1, 0], [0, 1]], [1, 0]) + assert batch.get_observable_mask(0) == 1 + assert batch.get_observable_mask(1) == 0 + + def test_num_shots(self): + batch = SampleBatch([[0, 0], [1, 1], [0, 1]], [0, 0, 0]) + assert batch.num_shots == 3 + + def test_ragged_rows_longer_rejected(self): + with pytest.raises(ValueError, match=r"row 1.*length 3.*expected 2"): + SampleBatch([[1, 0], [0, 1, 1]], [0, 0]) + + def test_ragged_rows_shorter_rejected(self): + with pytest.raises(ValueError, match=r"row 2.*length 1.*expected 2"): + SampleBatch([[1, 0], [0, 1], [0]], [0, 0, 0]) + + def test_length_mismatch_rejected(self): + with pytest.raises(ValueError, match="must have same length"): + SampleBatch([[1, 0]], [0, 0]) + + def test_empty_batch(self): + batch = SampleBatch([], []) + assert batch.num_shots == 0 + + +class TestGeneratedSampleBatch: + @pytest.fixture + def d3_setup(self): + from pecos.qec.surface import SurfacePatch + from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model + + patch = SurfacePatch.create(distance=3) + tc = _build_surface_tick_circuit_for_native_model( + patch, + 6, + "Z", + circuit_source="abstract", + ) + sampler = DemSampler.from_circuit( + tc, + p1=0.005, + p2=0.005, + p_meas=0.005, + p_prep=0.005, + ) + return sampler, tc + + def test_num_shots(self, d3_setup): + sampler, _ = d3_setup + batch = sampler.generate_samples(100, seed=42) + assert batch.num_shots == 100 + + def test_get_syndrome_shape(self, d3_setup): + sampler, _ = d3_setup + batch = sampler.generate_samples(10, seed=42) + syn = batch.get_syndrome(0) + assert len(syn) == sampler.num_detectors + + def test_get_observable_mask_type(self, d3_setup): + sampler, _ = d3_setup + batch = sampler.generate_samples(10, seed=42) + mask = batch.get_observable_mask(0) + assert isinstance(mask, int) + + def test_decode_count(self, d3_setup): + import stim + from pecos.qec.surface.circuit_builder import tick_circuit_to_stim + + sampler, tc = d3_setup + noise = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + stim_str = tick_circuit_to_stim(tc, **noise) + dem_str = str( + stim.Circuit(stim_str).detector_error_model(decompose_errors=True), + ) + + batch = sampler.generate_samples(1000, seed=42) + errors = batch.decode_count(dem_str, "pymatching") + assert isinstance(errors, int) + assert 0 <= errors <= 1000 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 new file mode 100644 index 000000000..47b0ca312 --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_traced_qis_clifford_pipeline.py @@ -0,0 +1,290 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Smoke tests for the traced-QIS surface-code route after Clifford lowering.""" + +import random + +from pecos.qec.surface import SurfacePatch +from pecos.qec.surface.decode import _build_surface_tick_circuit_for_native_model +from pecos.quantum import TickCircuit +from pecos_rslib_exp import ( + Mast, + StabMps, + depolarizing, + fault_catalog, + meas_sampling, + sim_neo, + stabilizer, + statevec, +) + +ONE_Q_INVERSES = { + "X": "X", + "Y": "Y", + "Z": "Z", + "H": "H", + "F": "Fdg", + "Fdg": "F", + "SX": "SXdg", + "SXdg": "SX", + "SY": "SYdg", + "SYdg": "SY", + "SZ": "SZdg", + "SZdg": "SZ", +} + +TWO_Q_INVERSES = { + "CX": "CX", + "CY": "CY", + "CZ": "CZ", + "SXX": "SXXdg", + "SXXdg": "SXX", + "SYY": "SYYdg", + "SYYdg": "SYY", + "SZZ": "SZZdg", + "SZZdg": "SZZ", + "SWAP": "SWAP", +} + +TICK_1Q_METHODS = { + "X": "x", + "Y": "y", + "Z": "z", + "H": "h", + "F": "f", + "Fdg": "fdg", + "SX": "sx", + "SXdg": "sxdg", + "SY": "sy", + "SYdg": "sydg", + "SZ": "sz", + "SZdg": "szdg", +} + +TICK_2Q_METHODS = { + "CX": "cx", + "CY": "cy", + "CZ": "cz", + "SXX": "sxx", + "SXXdg": "sxxdg", + "SYY": "syy", + "SYYdg": "syydg", + "SZZ": "szz", + "SZZdg": "szzdg", + "SWAP": "swap", +} + + +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() + return tc + + +def traced_qis_noise(): + return depolarizing().p1(0.0003).p2(0.003).p_meas(0.0015).p_prep(0.0015) + + +def zero_noise(): + return depolarizing().p1(0).p2(0).p_meas(0).p_prep(0) + + +def build_explicit_clifford_gate_circuit(): + tc = TickCircuit() + tc.tick().szdg([0]) + tc.tick().sx([0]) + tc.tick().sxdg([1]) + tc.tick().sy([0]) + tc.tick().sydg([1]) + tc.tick().f([0]) + tc.tick().fdg([1]) + tc.tick().cy([(0, 1)]) + tc.tick().cz([(0, 1)]) + tc.tick().sxx([(0, 1)]) + tc.tick().sxxdg([(0, 1)]) + tc.tick().syy([(0, 1)]) + tc.tick().syydg([(0, 1)]) + tc.tick().szz([(0, 1)]) + tc.tick().szzdg([(0, 1)]) + tc.tick().swap([(0, 1)]) + tc.tick().mz([0, 1]) + tc.set_meta("num_measurements", "2") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + return tc + + +def random_standard_clifford_sequence(seed, depth=14, num_qubits=3): + rng = random.Random(seed) + sequence = [] + for _ in range(depth): + if rng.random() < 0.55: + gate = rng.choice(tuple(ONE_Q_INVERSES)) + qubits = (rng.randrange(num_qubits),) + else: + gate = rng.choice(tuple(TWO_Q_INVERSES)) + q0, q1 = rng.sample(range(num_qubits), 2) + qubits = (q0, q1) + sequence.append((gate, qubits)) + return sequence + + +def inverse_standard_clifford_sequence(sequence): + inverse = [] + for gate, qubits in reversed(sequence): + if len(qubits) == 1: + inverse.append((ONE_Q_INVERSES[gate], qubits)) + else: + inverse.append((TWO_Q_INVERSES[gate], qubits)) + return inverse + + +def apply_tick_gate(tc, gate, qubits): + tick = tc.tick() + if len(qubits) == 1: + getattr(tick, TICK_1Q_METHODS[gate])([qubits[0]]) + else: + getattr(tick, TICK_2Q_METHODS[gate])([(qubits[0], qubits[1])]) + + +def build_mirrored_random_clifford_circuit(seed, num_qubits=3): + sequence = random_standard_clifford_sequence(seed, num_qubits=num_qubits) + tc = TickCircuit() + tc.tick().pz(list(range(num_qubits))) + for gate, qubits in sequence + inverse_standard_clifford_sequence(sequence): + apply_tick_gate(tc, gate, qubits) + tc.tick().mz(list(range(num_qubits))) + tc.set_meta("num_measurements", str(num_qubits)) + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + return tc, sequence + + +def run_direct_wrapper_mirrored_circuit(sim, sequence, num_qubits=3): + for q in range(num_qubits): + sim.run_1q_gate("PZ", q) + + for gate, qubits in sequence + inverse_standard_clifford_sequence(sequence): + if len(qubits) == 1: + sim.run_1q_gate(gate, qubits[0]) + else: + sim.run_2q_gate(gate, qubits) + + return [sim.run_1q_gate("MZ", q) for q in range(num_qubits)] + + +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() + + assert result.num_shots == shots + assert result.num_measurements == int(tc.get_meta("num_measurements")) + assert len(result[0]) == result.num_measurements + + +def test_fault_catalog_builds_on_lowered_traced_qis_surface_code(): + tc = build_lowered_traced_qis_surface_code() + + catalog = fault_catalog(tc, traced_qis_noise()) + first = next(catalog.fault_configurations(1)) + + assert len(catalog) > 0 + assert len(first.locations) == 1 + assert len(first.faults) == 1 + assert first.locations[0] is catalog.locations[first.location_indices[0]] + + +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() + catalog = fault_catalog(tc, noise) + first_fault = next(catalog.fault_configurations(1)) + + assert result.num_shots == 3 + assert result.num_measurements == int(tc.get_meta("num_measurements")) + assert len(catalog) > 0 + assert len(first_fault.locations) == 1 + assert len(first_fault.faults) == 1 + + +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() + assert result.num_shots == 3 + assert result.num_measurements == 2 + + catalog = fault_catalog(tc, noise) + # Structural catalog includes all locations (including p_meas=0 and p_prep=0). + # Count only alternatives at locations with nonzero channel probability. + nonzero_alts = sum(len(loc.faults) for loc in catalog if loc.channel_probability > 0.0) + assert nonzero_alts == 156 + + +def test_sim_neo_native_backends_accept_face_gates(): + tc = TickCircuit() + tc.tick().pz([0]) + tc.tick().f([0]) + tc.tick().fdg([0]) + tc.tick().mz([0]) + tc.set_meta("num_measurements", "1") + tc.set_meta("detectors", "[]") + tc.set_meta("observables", "[]") + + for backend in (stabilizer(), statevec()): + result = sim_neo(tc).quantum(backend).noise(zero_noise()).shots(2).seed(123).run() + assert result.num_measurements == 1 + assert all(result[shot][0] == 0 for shot in range(result.num_shots)) + + +def test_direct_exp_wrappers_accept_standard_clifford_names(): + for sim in (StabMps(2, seed=123), Mast(2, 1, seed=123)): + for gate in ("I", "X", "Y", "Z", "H", "F", "Fdg", "SX", "SXdg", "SY", "SYdg", "SZ", "SZdg"): + sim.run_1q_gate(gate, 0) + + for gate in ( + "CX", + "CY", + "CZ", + "SXX", + "SXXdg", + "SYY", + "SYYdg", + "SZZ", + "SZZdg", + "SWAP", + ): + sim.run_2q_gate(gate, (0, 1)) + + +def test_direct_exp_wrappers_face_inverse_is_deterministic(): + for sim in (StabMps(1, seed=123), Mast(1, 1, seed=123)): + sim.run_1q_gate("PZ", 0) + sim.run_1q_gate("F", 0) + sim.run_1q_gate("Fdg", 0) + assert sim.run_1q_gate("MZ", 0) == 0 + + +def test_random_mirrored_standard_clifford_circuits_match_across_backends(): + expected = [0, 0, 0] + + for seed in (7, 19, 41): + tc, sequence = build_mirrored_random_clifford_circuit(seed) + + backend_results = {} + for name, backend in (("stabilizer", stabilizer()), ("statevec", statevec())): + result = sim_neo(tc).quantum(backend).noise(zero_noise()).shots(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)] + backend_results["Mast"] = [run_direct_wrapper_mirrored_circuit(Mast(3, 1, seed=seed), sequence)] + + for name, rows in backend_results.items(): + assert all(row == expected for row in rows), (seed, name, rows) 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 new file mode 100644 index 000000000..3b194a7dd --- /dev/null +++ b/python/quantum-pecos/tests/qec/test_traced_qis_slow_integration.py @@ -0,0 +1,182 @@ +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 + +"""Slow traced-QIS integration tests for the raw-measurement pipeline.""" + +import json +import math + +import numpy as np +import pytest +from pecos.qec.surface import SurfacePatch +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 + +pymatching = pytest.importorskip("pymatching") +stim = pytest.importorskip("stim") + +pytestmark = pytest.mark.slow + + +def _noise_args(error_rate=0.003): + return { + "p1": error_rate * 0.1, + "p2": error_rate, + "p_meas": error_rate * 0.5, + "p_prep": error_rate * 0.5, + } + + +def _depolarizing_noise(noise_args): + return ( + depolarizing() + .p1(noise_args["p1"]) + .p2(noise_args["p2"]) + .p_meas(noise_args["p_meas"]) + .p_prep(noise_args["p_prep"]) + ) + + +def _build_lowered_traced_qis_surface_code(distance, rounds, basis="Z"): + patch = SurfacePatch.create(distance=distance) + circuit = _build_surface_tick_circuit_for_native_model(patch, rounds, basis, circuit_source="traced_qis") + circuit.lower_clifford_rotations() + return circuit + + +def _pymatching_decoder(circuit, noise_args): + stim_str = tick_circuit_to_stim(circuit, **noise_args) + dem = stim.Circuit(stim_str).detector_error_model(decompose_errors=True) + return pymatching.Matching.from_detector_error_model(dem) + + +def _extract_observable_mask(row, observables, num_measurements): + mask = 0 + for obs_index, obs in enumerate(observables): + value = 0 + for rec in obs["records"]: + idx = num_measurements + rec + if 0 <= idx < len(row): + value ^= int(row[idx]) + if value: + mask |= 1 << obs_index + return mask + + +def _decode_raw_measurements(result, circuit, matching, shots): + detectors = json.loads(circuit.get_meta("detectors")) + observables = json.loads(circuit.get_meta("observables") or "[]") + num_measurements = int(circuit.get_meta("num_measurements")) + syndrome = np.zeros(len(detectors), dtype=np.uint8) + + errors = 0 + for shot_index in range(shots): + row = result[shot_index] + syndrome.fill(0) + + for det_index, det in enumerate(detectors): + value = 0 + for rec in det["records"]: + idx = num_measurements + rec + if 0 <= idx < len(row): + value ^= int(row[idx]) + syndrome[det_index] = value + + predicted = matching.decode(syndrome) + predicted_mask = sum(int(bit) << index for index, bit in enumerate(predicted)) + actual_mask = _extract_observable_mask(row, observables, num_measurements) + errors += predicted_mask != actual_mask + + return errors + + +def _decode_native_dem_samples(circuit, noise_args, matching, shots, seed): + sampler = DemSampler.from_circuit(circuit, **noise_args) + batch = sampler.generate_samples(shots, seed=seed) + syndrome = np.zeros(sampler.num_detectors, dtype=np.uint8) + + errors = 0 + for shot_index in range(shots): + sampled_syndrome = batch.get_syndrome(shot_index) + for det_index in range(sampler.num_detectors): + syndrome[det_index] = sampled_syndrome[det_index] + predicted = matching.decode(syndrome) + predicted_mask = sum(int(bit) << index for index, bit in enumerate(predicted)) + errors += predicted_mask != batch.get_observable_mask(shot_index) + + return errors + + +def _assert_statistically_consistent(meas_errors, native_errors, shots): + meas_ler = meas_errors / shots + native_ler = native_errors / shots + pooled = (meas_errors + native_errors) / (2 * shots) + variance = 2 * max(pooled * (1 - pooled), 1 / shots) / shots + tolerance = max(0.04, 7 * math.sqrt(variance)) + + assert abs(meas_ler - native_ler) <= tolerance, ( + "meas_sampling and native DEM LERs differ more than stochastic tolerance: " + f"meas={meas_errors}/{shots} ({meas_ler:.4f}), " + f"native={native_errors}/{shots} ({native_ler:.4f}), " + f"tolerance={tolerance:.4f}" + ) + + +@pytest.mark.parametrize( + ("distance", "rounds", "shots"), + [ + (3, 6, 2_500), + (5, 10, 2_500), + ], +) +def test_traced_qis_meas_sampling_ler_tracks_native_dem_pymatching(distance, rounds, shots): + noise_args = _noise_args() + circuit = _build_lowered_traced_qis_surface_code(distance, rounds) + matching = _pymatching_decoder(circuit, noise_args) + + raw_result = ( + sim_neo(circuit).quantum(meas_sampling()).noise(_depolarizing_noise(noise_args)).shots(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) + + assert 0 <= meas_errors <= shots + assert 0 <= native_errors <= shots + _assert_statistically_consistent(meas_errors, native_errors, shots) + + +def test_d3_traced_qis_zero_noise_pymatching_pipeline_has_no_logical_errors(): + noise_args = _noise_args(error_rate=0.0) + circuit = _build_lowered_traced_qis_surface_code(distance=3, rounds=3) + matching = _pymatching_decoder(circuit, noise_args) + shots = 64 + + raw_result = ( + sim_neo(circuit).quantum(meas_sampling()).noise(_depolarizing_noise(noise_args)).shots(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) + + assert meas_errors == 0 + assert native_errors == 0 + + +def test_d3_traced_qis_fault_catalog_builds_with_all_noise_channels_enabled(): + noise_args = _noise_args() + circuit = _build_lowered_traced_qis_surface_code(distance=3, rounds=9) + catalog = fault_catalog(circuit, _depolarizing_noise(noise_args)) + + alternative_counts = [len(location.faults) for location in catalog] + assert len(catalog) > 100 + assert 1 in alternative_counts + assert 3 in alternative_counts + assert 15 in alternative_counts + assert sum(alternative_counts) > 1_000 + + first_event = next(catalog.fault_configurations(1)) + assert len(first_event.locations) == 1 + assert len(first_event.faults) == 1 + assert first_event.locations[0] is catalog.locations[first_event.location_indices[0]] + assert first_event.faults[0] is first_event.locations[0].faults[first_event.alternative_indices[0]] diff --git a/ruff.toml b/ruff.toml index 8855358c6..e012768bd 100644 --- a/ruff.toml +++ b/ruff.toml @@ -179,7 +179,13 @@ ignore = [ "PLC0415", # Import inside function - lazy loading of guppy, stim, json "SLF001", # Private member access - accessing patch and circuit internals ] +"python/quantum-pecos/src/pecos/qec/surface/logical_circuit.py" = [ + "PLC0415", # Lazy imports keep optional decoder/stim/Rust bindings out of import time +] # QEC analysis modules +"python/quantum-pecos/src/pecos/qec/analysis.py" = [ + "PLC0415", # Lazy imports keep optional Rust/Python analysis dependencies out of import time +] "python/quantum-pecos/src/pecos/qec/analysis/dem_builder.py" = [ "SLF001", # Private member access - accessing internal circuit/tick data ] @@ -197,6 +203,8 @@ ignore = [ # Test files "python/*/tests/**/*.py" = [ + "ANN", # Test functions and fixtures are clearer without exhaustive type annotations + "D", # Test names describe behavior; per-test docstrings add noise "F401", # Imported but unused - OK in tests for import availability checks "INP001", # File is part of an implicit namespace package - OK for test directories "S101", # Use of `assert` detected - Assert is standard practice in test files @@ -240,8 +248,24 @@ ignore = [ ] # Scripts and examples - not packages -"scripts/**/*.py" = ["INP001", "S603", "PLC0415", "S301", "BLE001"] # Script files: no __init__.py, subprocess calls, lazy imports, pickle, broad except -"examples/**/*.py" = ["INP001", "BLE001"] # Example files don't need __init__.py and can use broad exception handling +"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 + "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 +] +"examples/**/*.py" = [ + "ANN", # Examples favor readable demonstration code over exhaustive annotations + "D", # Module/function names and surrounding text document examples + "INP001", # Example files are not packages + "BLE001", # Examples may catch broad optional-dependency/runtime failures + "S108", # Examples commonly default to /tmp output paths + "S311", # Example sweeps use deterministic pseudo-random circuits, not crypto +] # Jupyter notebooks - exploratory code, less strict than library code diff --git a/scripts/bench_raw_meas_sampling.py b/scripts/bench_raw_meas_sampling.py new file mode 100644 index 000000000..7622c1cfd --- /dev/null +++ b/scripts/bench_raw_meas_sampling.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 +"""Benchmark: raw measurement sampling / detector DEM vs stabilizer simulation. + +Compares detector DEM (generate_samples), raw meas_sampling, and stabilizer. +Quick smoke test by default (~10s). Use --full for stable headline numbers. + +Usage: + uv run python scripts/bench_raw_meas_sampling.py # quick + .venv/bin/python scripts/bench_raw_meas_sampling.py --full # full (release) +""" + +import sys +import time + +from pecos.qec.surface import SurfacePatch +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, meas_sampling, sim_neo, stabilizer + +FULL = "--full" in sys.argv + + +def build(d): + patch = SurfacePatch.create(distance=d) + return _build_surface_tick_circuit_for_native_model(patch, 6, "Z", circuit_source="abstract") + + +def main(): + noise_args = {"p1": 0.005, "p2": 0.005, "p_meas": 0.005, "p_prep": 0.005} + depol = depolarizing().p1(0.005).p2(0.005).p_meas(0.005).p_prep(0.005) + mode = "full" if FULL else "quick" + + print(f"Raw measurement / detector DEM benchmark ({mode}): surface code, 6 rounds, p=0.005") + print("=" * 78) + + # ---- Section 1: Generation only ---- + gen_configs = ( + [(3, [100_000, 1_000_000]), (5, [100_000, 1_000_000]), (7, [100_000, 1_000_000])] + if FULL + else [(3, [10_000, 100_000]), (5, [10_000, 100_000])] + ) + stab_limit = 100_000 if FULL else 10_000 + + print() + print("1. Generation only (no decoding):") + print(f"{'d':>3} {'shots':>10} | {'Det DEM':>9} | {'Raw meas':>9} | {'Stab sim':>9} | {'stab/det':>9}") + print("-" * 72) + + for d, shot_list in gen_configs: + tc = build(d) + sampler = DemSampler.from_circuit(tc, **noise_args) + + for shots in shot_list: + t0 = time.perf_counter() + _ = sampler.generate_samples(shots, seed=42) + t_det = time.perf_counter() - t0 + + t0 = time.perf_counter() + _ = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(42).run() + t_raw = time.perf_counter() - t0 + + if shots <= stab_limit: + t0 = time.perf_counter() + _ = sim_neo(tc).quantum(stabilizer()).noise(depol).shots(shots).seed(42).run() + t_stab = time.perf_counter() - t0 + stab_s = f"{t_stab * 1000:>8.0f}ms" + ratio_s = f"{t_stab / t_det:>8.0f}x" + else: + stab_s = ratio_s = f"{'--':>9}" + + label = f"d={d}" if shots == shot_list[0] else "" + print( + f"{label:>3} {shots:>10,} | {t_det * 1000:>8.1f}ms | {t_raw * 1000:>8.1f}ms | {stab_s} | {ratio_s}", + ) + print() + + # ---- Section 2: Generate + decode end-to-end ---- + dec_configs = [(3, [10_000, 100_000]), (5, [10_000, 100_000])] if FULL else [(3, [1_000, 10_000]), (5, [1_000])] + + print("2. Detector DEM generate + pymatching decode:") + print(f"{'d':>3} {'shots':>10} | {'generate':>9} | {'decode':>9} | {'total':>9} | {'gen%':>6}") + print("-" * 62) + + import stim + from pecos.qec.surface.circuit_builder import tick_circuit_to_stim + + for d, shot_list in dec_configs: + tc = build(d) + sampler = DemSampler.from_circuit(tc, **noise_args) + stim_str = tick_circuit_to_stim(tc, **noise_args) + dem_str = str(stim.Circuit(stim_str).detector_error_model(decompose_errors=True)) + + for shots in shot_list: + t0 = time.perf_counter() + batch = sampler.generate_samples(shots, seed=42) + t_gen = time.perf_counter() - t0 + + t0 = time.perf_counter() + _ = batch.decode_count(dem_str, "pymatching") + t_dec = time.perf_counter() - t0 + + t_total = t_gen + t_dec + gen_pct = t_gen / t_total * 100 + + label = f"d={d}" if shots == shot_list[0] else "" + print( + f"{label:>3} {shots:>10,} | {t_gen * 1000:>8.1f}ms | " + f"{t_dec * 1000:>8.1f}ms | {t_total * 1000:>8.1f}ms | {gen_pct:>5.1f}%", + ) + print() + + print("Notes:") + print(" generate_samples is 3-6x faster after columnar SampleBatch.") + print(" End-to-end generate+decode is decode-dominated (<1% generation).") + if not FULL: + print(" Use --full for larger shot counts and stable headline numbers.") + + +if __name__ == "__main__": + main() diff --git a/scripts/check_python_workspace.py b/scripts/check_python_workspace.py new file mode 100644 index 000000000..5d0259b09 --- /dev/null +++ b/scripts/check_python_workspace.py @@ -0,0 +1,241 @@ +#!/usr/bin/env python3 +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 +"""Validate PECOS Python workspace metadata. + +This check is intentionally narrower than a full packaging linter. It guards +the invariants that tend to drift in this repository: package versions, +workspace membership, internal dependency pins, and uv workspace sources. +""" + +from __future__ import annotations + +import re +import sys +from dataclasses import dataclass +from pathlib import Path +from typing import Any + +try: + import tomllib +except ModuleNotFoundError: # pragma: no cover - Python 3.10 fallback + try: + import tomli as tomllib # type: ignore[no-redef] + except ModuleNotFoundError: + print("error: Python 3.11+ or the 'tomli' package is required", file=sys.stderr) + sys.exit(2) + + +REPO_ROOT = Path(__file__).resolve().parents[1] +ROOT_PYPROJECT = REPO_ROOT / "pyproject.toml" +DEPENDENCY_NAME_RE = re.compile(r"^\s*([A-Za-z0-9_.-]+)") + + +@dataclass(frozen=True) +class Package: + path: Path + rel_dir: str + name: str + normalized_name: str + version: str + data: dict[str, Any] + + +def normalize_name(name: str) -> str: + return re.sub(r"[-_.]+", "-", name).lower() + + +def load_toml(path: Path) -> dict[str, Any]: + with path.open("rb") as handle: + return tomllib.load(handle) + + +def fail(errors: list[str], message: str) -> None: + errors.append(message) + + +def rel(path: Path) -> str: + return path.relative_to(REPO_ROOT).as_posix() + + +def load_package(path: Path, errors: list[str]) -> Package | None: + data = load_toml(path) + project = data.get("project") + if not isinstance(project, dict): + fail(errors, f"{rel(path)}: missing [project] table") + return None + + name = project.get("name") + version = project.get("version") + if not isinstance(name, str) or not name: + fail(errors, f"{rel(path)}: missing [project].name") + return None + if not isinstance(version, str) or not version: + fail(errors, f"{rel(path)}: missing [project].version") + return None + + return Package( + path=path, + rel_dir=rel(path.parent), + name=name, + normalized_name=normalize_name(name), + version=version, + data=data, + ) + + +def dependency_name(requirement: str) -> str | None: + match = DEPENDENCY_NAME_RE.match(requirement) + if match is None: + return None + return normalize_name(match.group(1)) + + +def has_exact_version_pin(requirement: str, version: str) -> bool: + return re.search(rf"(^|[^=!<>~])==\s*{re.escape(version)}(\s*(;|,|$))", requirement) is not None + + +def iter_dependency_lists(data: dict[str, Any]) -> list[tuple[str, list[Any]]]: + lists: list[tuple[str, list[Any]]] = [] + project = data.get("project", {}) + if isinstance(project, dict): + dependencies = project.get("dependencies", []) + if isinstance(dependencies, list): + lists.append(("[project].dependencies", dependencies)) + + optional = project.get("optional-dependencies", {}) + if isinstance(optional, dict): + for extra, deps in sorted(optional.items()): + if isinstance(deps, list): + lists.append((f"[project.optional-dependencies].{extra}", deps)) + + dependency_groups = data.get("dependency-groups", {}) + if isinstance(dependency_groups, dict): + for group, deps in sorted(dependency_groups.items()): + if isinstance(deps, list): + lists.append((f"[dependency-groups].{group}", deps)) + + return lists + + +def internal_dependencies(package: Package, workspace_names: set[str], errors: list[str]) -> set[str]: + internal: set[str] = set() + for section, deps in iter_dependency_lists(package.data): + for dep in deps: + if not isinstance(dep, str): + fail(errors, f"{rel(package.path)}: {section} contains non-string dependency {dep!r}") + continue + dep_name = dependency_name(dep) + if dep_name is None or dep_name not in workspace_names or dep_name == package.normalized_name: + continue + internal.add(dep_name) + if not has_exact_version_pin(dep, package.version): + fail( + errors, + f"{rel(package.path)}: {section} dependency {dep!r} must pin " + f"workspace package version =={package.version}", + ) + return internal + + +def workspace_sources(package: Package, errors: list[str]) -> set[str]: + tool = package.data.get("tool", {}) + uv = tool.get("uv", {}) if isinstance(tool, dict) else {} + sources = uv.get("sources", {}) if isinstance(uv, dict) else {} + if not isinstance(sources, dict): + fail(errors, f"{rel(package.path)}: [tool.uv.sources] must be a table") + return set() + + names: set[str] = set() + for name, source in sources.items(): + normalized = normalize_name(name) + if not isinstance(source, dict) or source.get("workspace") is not True: + continue + names.add(normalized) + return names + + +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: + return + if cuda_extra != cuda_group: + fail( + errors, + "pyproject.toml: [project.optional-dependencies].cuda and [dependency-groups].cuda must stay identical", + ) + + +def main() -> int: + errors: list[str] = [] + + root = load_package(ROOT_PYPROJECT, errors) + package_paths = sorted((REPO_ROOT / "python").rglob("pyproject.toml")) + packages = [pkg for path in package_paths if (pkg := load_package(path, errors)) is not None] + if root is None: + for error in errors: + print(f"error: {error}", file=sys.stderr) + return 1 + + all_packages = [root, *packages] + workspace_names = {pkg.normalized_name for pkg in all_packages} + + for pkg in all_packages: + if pkg.version != root.version: + fail( + errors, + f"{rel(pkg.path)}: version {pkg.version!r} does not match root version {root.version!r}", + ) + + root_tool = root.data.get("tool", {}) + root_uv = root_tool.get("uv", {}) if isinstance(root_tool, dict) else {} + workspace = root_uv.get("workspace", {}) if isinstance(root_uv, dict) else {} + members = workspace.get("members") if isinstance(workspace, dict) else None + expected_members = sorted(pkg.rel_dir for pkg in packages) + if not isinstance(members, list) or any(not isinstance(member, str) for member in members): + fail(errors, "pyproject.toml: [tool.uv.workspace].members must be a string list") + elif sorted(members) != expected_members: + fail( + errors, + "pyproject.toml: [tool.uv.workspace].members does not match Python package directories\n" + f" expected: {expected_members}\n" + f" found: {sorted(members)}", + ) + + check_cuda_extra_group(root.data, errors) + + for pkg in all_packages: + internal = internal_dependencies(pkg, workspace_names, errors) + sources = workspace_sources(pkg, errors) + missing_sources = sorted(internal - sources) + extra_sources = sorted((sources & workspace_names) - internal) + if missing_sources: + fail( + errors, + f"{rel(pkg.path)}: missing [tool.uv.sources] workspace entries for {missing_sources}", + ) + if extra_sources: + fail( + errors, + f"{rel(pkg.path)}: unused internal [tool.uv.sources] workspace entries {extra_sources}", + ) + + if errors: + for error in errors: + print(f"error: {error}", file=sys.stderr) + return 1 + + print( + f"Python workspace metadata OK: {len(packages)} packages, " + f"version {root.version}, {len(expected_members)} uv workspace members", + ) + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/scripts/compare_meas_sampling_pipeline.py b/scripts/compare_meas_sampling_pipeline.py new file mode 100644 index 000000000..5fc987a36 --- /dev/null +++ b/scripts/compare_meas_sampling_pipeline.py @@ -0,0 +1,223 @@ +#!/usr/bin/env python3 +# Copyright 2026 The PECOS Developers +# Licensed under the Apache License, Version 2.0 +"""Compare meas_sampling pipeline against native DEM sampler on surface-code memory. + +Uses the Guppy → traced QIS → TickCircuit pipeline (lowered to Clifford gates). +Decodes with PyMatching. Compares logical error rates and timing. + +Usage: + .venv/bin/python scripts/compare_meas_sampling_pipeline.py + .venv/bin/python scripts/compare_meas_sampling_pipeline.py --distances 3 5 7 --shots 10000 +""" + +from __future__ import annotations + +import argparse +import json +import time + +import numpy as np +import pymatching +import stim +from pecos.qec.surface import SurfacePatch +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, meas_sampling, sim_neo + + +def build_circuit(distance, rounds, basis="Z"): + """Build a traced-QIS surface-code TickCircuit, lowered to Clifford gates.""" + patch = SurfacePatch.create(distance=distance) + tc = _build_surface_tick_circuit_for_native_model( + patch, + rounds, + basis, + circuit_source="traced_qis", + ) + # Lower R1XY/RZ rotations to standard Clifford gates (H, SZ, SZdg, etc.) + tc.lower_clifford_rotations() + return tc + + +def get_pymatching_decoder(tc, noise_args): + """Build a PyMatching decoder from a circuit's Stim DEM.""" + stim_str = tick_circuit_to_stim(tc, **noise_args) + dem = stim.Circuit(stim_str).detector_error_model(decompose_errors=True) + return pymatching.Matching.from_detector_error_model(dem) + + +def run_meas_sampling(tc, noise_args, shots, seed): + """Sample raw measurements via meas_sampling.""" + depol = ( + depolarizing() + .p1(noise_args["p1"]) + .p2(noise_args["p2"]) + .p_meas( + noise_args["p_meas"], + ) + .p_prep(noise_args["p_prep"]) + ) + + t0 = time.perf_counter() + result = sim_neo(tc).quantum(meas_sampling()).noise(depol).shots(shots).seed(seed).run() + t_sample = time.perf_counter() - t0 + return result, t_sample + + +def extract_and_decode(result, tc, matching, shots): + """Extract detection events from raw measurements and decode with PyMatching.""" + det_json = json.loads(tc.get_meta("detectors")) + obs_json_str = tc.get_meta("observables") + obs_json = json.loads(obs_json_str) if obs_json_str else [] + num_meas = int(tc.get_meta("num_measurements")) + num_dets = len(det_json) + + t0 = time.perf_counter() + + errors = 0 + syndrome = np.zeros(num_dets, dtype=np.uint8) + + for shot_idx in range(shots): + row = result[shot_idx] + + # Extract detector events + syndrome.fill(0) + for i, det in enumerate(det_json): + val = 0 + for rec in det["records"]: + idx = num_meas + rec + if 0 <= idx < len(row): + val ^= row[idx] + syndrome[i] = val + + # Extract observable flips + actual_mask = 0 + for i, obs in enumerate(obs_json): + val = 0 + for rec in obs["records"]: + idx = num_meas + rec + if 0 <= idx < len(row): + val ^= row[idx] + if val: + actual_mask |= 1 << i + + # Decode + predicted = matching.decode(syndrome) + pred_mask = sum(int(v) << j for j, v in enumerate(predicted)) + if pred_mask != actual_mask: + errors += 1 + + t_decode = time.perf_counter() - t0 + return errors, t_decode + + +def run_native_sampler(tc, noise_args, matching, shots, seed): + """Sample + decode via the native DEM sampler path.""" + sampler = DemSampler.from_circuit(tc, **noise_args) + num_dets = sampler.num_detectors + + t0 = time.perf_counter() + batch = sampler.generate_samples(shots, seed=seed) + t_sample = time.perf_counter() - t0 + + t0 = time.perf_counter() + errors = 0 + syndrome = np.zeros(num_dets, dtype=np.uint8) + + for i in range(shots): + syn = batch.get_syndrome(i) + for j in range(num_dets): + syndrome[j] = syn[j] + + predicted = matching.decode(syndrome) + pred_mask = sum(int(v) << j for j, v in enumerate(predicted)) + if pred_mask != batch.get_observable_mask(i): + errors += 1 + + t_decode = time.perf_counter() - t0 + return errors, t_sample, t_decode + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + parser.add_argument("--distances", type=int, nargs="+", default=[3, 5]) + parser.add_argument( + "--rounds-per-d", + type=int, + default=3, + help="Syndrome rounds = distance * rounds_per_d", + ) + parser.add_argument("--shots", type=int, default=5000) + parser.add_argument("--error-rate", type=float, default=0.003) + parser.add_argument("--seed", type=int, default=42) + args = parser.parse_args() + + p = args.error_rate + noise_args = {"p1": p * 0.1, "p2": p, "p_meas": p * 0.5, "p_prep": p * 0.5} + + print("=" * 80) + print("meas_sampling vs native DEM sampler: traced-QIS surface-code memory + PyMatching") + print("=" * 80) + print(f" shots={args.shots}, p={p}") + print( + f" noise: p1={noise_args['p1']:.1e} p2={noise_args['p2']:.1e} " + f"p_meas={noise_args['p_meas']:.1e} p_prep={noise_args['p_prep']:.1e}", + ) + print(" circuit: Guppy -> traced QIS -> lower_clifford_rotations()") + print() + + header = ( + f"{'d':>3} {'rounds':>6} | {'backend':>15} | {'sample':>8} | " + f"{'decode':>8} | {'total':>8} | {'LER':>8} | {'errors':>10}" + ) + print(header) + print("-" * len(header)) + + for d in args.distances: + rounds = d * args.rounds_per_d + tc = build_circuit(d, rounds) + + matching = get_pymatching_decoder(tc, noise_args) + + # --- meas_sampling --- + result, t_sample_ms = run_meas_sampling(tc, noise_args, args.shots, args.seed) + errors_ms, t_decode_ms = extract_and_decode(result, tc, matching, args.shots) + ler_ms = errors_ms / args.shots + t_total_ms = t_sample_ms + t_decode_ms + + print( + f"d={d:>1} {rounds:>6} | {'meas_sampling':>15} | {t_sample_ms*1000:>7.0f}ms | " + f"{t_decode_ms*1000:>7.0f}ms | {t_total_ms*1000:>7.0f}ms | " + f"{ler_ms:>7.4f} | {errors_ms:>5}/{args.shots}", + ) + + # --- native DEM sampler --- + errors_ns, t_sample_ns, t_decode_ns = run_native_sampler( + tc, + noise_args, + matching, + args.shots, + args.seed, + ) + ler_ns = errors_ns / args.shots + t_total_ns = t_sample_ns + t_decode_ns + + print( + f" {' ':>6} | {'native_sampler':>15} | {t_sample_ns*1000:>7.0f}ms | " + f"{t_decode_ns*1000:>7.0f}ms | {t_total_ns*1000:>7.0f}ms | " + f"{ler_ns:>7.4f} | {errors_ns:>5}/{args.shots}", + ) + print() + + print("Notes:") + print(" Circuit: Guppy surface code -> traced QIS -> lower_clifford_rotations()") + print(" Decoder: PyMatching (stim DEM, decompose_errors=True)") + print(" meas_sampling: geometric raw measurement DEM sampler + Python extraction") + print(" native_sampler: DemSampler.generate_samples (detector events directly)") + print(" LER differences from different RNG streams, not systematic bias.") + + +if __name__ == "__main__": + main() diff --git a/scripts/docs/generate_doc_tests.py b/scripts/docs/generate_doc_tests.py index 80c6d917b..d716c37af 100755 --- a/scripts/docs/generate_doc_tests.py +++ b/scripts/docs/generate_doc_tests.py @@ -21,9 +21,10 @@ Supported markers in markdown: or - Skip this block - Skip if CUDA+cupy not available - - Skip if CUDA Rust bindings not available + - Skip if CUDA Rust simulators cannot initialize - Expect error matching regex pattern - Expect stdout to contain text + - Skip if Python module is unavailable - Name the test function - Add @pytest.mark.slow - Continue from previous block's state @@ -69,6 +70,9 @@ class CodeBlock: skip_if_no_cuda_rust: bool = False expect_error: str | None = None expect_output: str | None = None + expect_output_block: str | None = None + expect_output_mode: str = "exact" # "exact" or "ellipsis" + required_modules: list[str] = field(default_factory=list) test_name: str | None = None marks: list[str] = field(default_factory=list) is_continuation: bool = False @@ -196,6 +200,9 @@ def _parse_marker_comment(comment: str) -> dict: "skip_if_no_cuda_rust": False, "expect_error": None, "expect_output": None, + "expect_output_block": False, + "expect_output_mode": "exact", + "required_modules": [], "test_name": None, "marks": [], "is_continuation": False, @@ -239,12 +246,24 @@ def _parse_marker_comment(comment: str) -> dict: result["expect_error"] = match.group(1).strip() result["skip"] = False # Don't skip, we want to test the error - # Check for expect-output - if "expect-output" in comment_lower: + # Check for expect-output-block (must check before expect-output substring match) + if "expect-output-block" in comment_lower: + result["expect_output_block"] = True + mode_match = re.search(r"expect-output-block:\s*(\w+)\s*-->", comment, re.IGNORECASE) + if mode_match: + result["expect_output_mode"] = mode_match.group(1).strip().lower() + # Check for expect-output (substring match) + elif "expect-output" in comment_lower: match = re.search(r"expect-output:\s*(.+?)\s*-->", comment, re.IGNORECASE) if match: result["expect_output"] = match.group(1).strip() + # Check for required Python modules + if "requires-module" in comment_lower: + match = re.search(r"requires-module:\s*(.+?)\s*-->", comment, re.IGNORECASE) + if match: + result["required_modules"] = [module.strip() for module in match.group(1).split(",") if module.strip()] + # Check for test-name match = re.search(r"test-name:\s*(\w+)", comment, re.IGNORECASE) if match: @@ -381,6 +400,21 @@ def extract_code_blocks(file_path: Path, language: str = "python") -> list[CodeB block_skip = attrs["skip"] or doc_skip block_skip_reason = attrs["skip_reason"] or doc_skip_reason + # If expect-output-block, look for a ```output fence after this code block + output_block_text = None + output_mode = attrs["expect_output_mode"] + if attrs["expect_output_block"]: + after_fence = content[match.end() :] + output_match = re.match(r"\s*```output\n(.*?)```", after_fence, re.DOTALL) + if output_match: + output_block_text = output_match.group(1).rstrip("\n") + else: + msg = ( + f"{file_path}:{line_number}: expect-output-block marker " + f"but no ```output fence found after code block" + ) + raise ValueError(msg) + block = CodeBlock( code=full_code, language=language, @@ -393,6 +427,9 @@ def extract_code_blocks(file_path: Path, language: str = "python") -> list[CodeB skip_if_no_cuda_rust=attrs["skip_if_no_cuda_rust"], expect_error=attrs["expect_error"], expect_output=attrs["expect_output"], + expect_output_block=output_block_text, + expect_output_mode=output_mode, + required_modules=attrs["required_modules"], test_name=attrs["test_name"], marks=attrs["marks"], is_continuation=attrs["is_continuation"], @@ -431,7 +468,7 @@ def generate_test_function(block: CodeBlock, file_stem: str) -> str: ) elif block.skip_if_no_cuda_rust: lines.append( - '@pytest.mark.skipif(not cuda_rust_available(), reason="CUDA Rust bindings not available")', + '@pytest.mark.skipif(not cuda_rust_available(), reason="CUDA Rust simulator runtime not available")', ) lines.extend(f"@pytest.mark.{mark}" for mark in block.marks) @@ -442,6 +479,8 @@ def generate_test_function(block: CodeBlock, file_stem: str) -> str: # Docstring with source file and line number for easy navigation lines.append(f' """Test from {block.source_file}:{block.line_number}."""') + lines.extend(f' pytest.importorskip("{module}")' for module in block.required_modules) + # Generate function body based on test type and language if block.language == "rust": if block.expect_error: @@ -450,6 +489,8 @@ def generate_test_function(block: CodeBlock, file_stem: str) -> str: lines.extend(_generate_rust_exec_body(block)) elif block.expect_error: lines.extend(_generate_expect_error_body(block)) + elif block.expect_output_block is not None: + lines.extend(_generate_expect_output_block_body(block)) elif block.expect_output: lines.extend(_generate_expect_output_body(block)) elif _uses_guppy_decorator(block.code): @@ -493,7 +534,7 @@ def _generate_guppy_body(block: CodeBlock) -> list[str]: " # Guppy needs file-based execution for inspect.getsourcelines()", " # Run in temp directory to avoid polluting project root with generated files", " with tempfile.TemporaryDirectory() as tmpdir:", - " temp_path = Path(tmpdir) / 'test_code.py'", + ' temp_path = Path(tmpdir) / "test_code.py"', " temp_path.write_text(code)", "", " result = subprocess.run(", @@ -519,10 +560,10 @@ def _generate_expect_error_body(block: CodeBlock) -> list[str]: # Guppy code needs subprocess execution # Run in temp directory to avoid polluting project root with generated files lines = [ + " import re", " import subprocess", " import sys", " import tempfile", - " import re", " from pathlib import Path", "", ' code = """', @@ -531,7 +572,7 @@ def _generate_expect_error_body(block: CodeBlock) -> list[str]: f' expected_pattern = r"{escaped_pattern}"', "", " with tempfile.TemporaryDirectory() as tmpdir:", - " temp_path = Path(tmpdir) / 'test_code.py'", + ' temp_path = Path(tmpdir) / "test_code.py"', " temp_path.write_text(code)", "", " result = subprocess.run(", @@ -542,16 +583,16 @@ def _generate_expect_error_body(block: CodeBlock) -> list[str]: " check=False,", " cwd=tmpdir,", " )", - " assert result.returncode != 0, 'Expected code to fail but it succeeded'", + ' assert result.returncode != 0, "Expected code to fail but it succeeded"', " assert re.search(expected_pattern, result.stderr), \\", ' f"Error did not match pattern {expected_pattern!r}:\\n{result.stderr}"', ] else: # Regular code can use subprocess with -c lines = [ + " import re", " import subprocess", " import sys", - " import re", "", ' code = """', *[line.rstrip() for line in escaped_code.split("\n")], @@ -565,7 +606,7 @@ def _generate_expect_error_body(block: CodeBlock) -> list[str]: " timeout=30,", " check=False,", " )", - " assert result.returncode != 0, 'Expected code to fail but it succeeded'", + ' assert result.returncode != 0, "Expected code to fail but it succeeded"', " assert re.search(expected_pattern, result.stderr), \\", ' f"Error did not match pattern {expected_pattern!r}:\\n{result.stderr}"', ] @@ -590,9 +631,9 @@ def _generate_rust_rustc_body(block: CodeBlock) -> list[str]: has_main = "fn main()" in block.code lines = [ + " import os", " import subprocess", " import tempfile", - " import os", " from pathlib import Path", "", ' code = """', @@ -659,9 +700,9 @@ def _generate_rust_expect_error_body(block: CodeBlock) -> list[str]: escaped_pattern = block.expect_error.replace('"', '\\"') if block.expect_error else "" return [ + " import re", " import subprocess", " import tempfile", - " import re", " from pathlib import Path", "", ' code = """', @@ -709,7 +750,7 @@ def _generate_expect_output_body(block: CodeBlock) -> list[str]: f' expected_output = "{escaped_output}"', "", " with tempfile.TemporaryDirectory() as tmpdir:", - " temp_path = Path(tmpdir) / 'test_code.py'", + ' temp_path = Path(tmpdir) / "test_code.py"', " temp_path.write_text(code)", "", " result = subprocess.run(", @@ -751,6 +792,50 @@ def _generate_expect_output_body(block: CodeBlock) -> list[str]: return lines +def _generate_expect_output_block_body(block: CodeBlock) -> list[str]: + """Generate test body that checks stdout matches expected output exactly. + + Uses Python's doctest.OutputChecker for matching. Supports exact mode + (default) and ellipsis mode (``...`` skips variable parts). + """ + escaped_code = block.code.replace("\\", "\\\\").replace('"""', '\\"\\"\\"') + escaped_expected = block.expect_output_block.replace("\\", "\\\\").replace('"""', '\\"\\"\\"') + use_ellipsis = block.expect_output_mode == "ellipsis" + + return [ + " import doctest", + " import subprocess", + " import sys", + "", + ' code = """', + *[line.rstrip() for line in escaped_code.split("\n")], + '"""', + ' expected = """', + *[line.rstrip() for line in escaped_expected.split("\n")], + '"""', + "", + " result = subprocess.run(", + ' [sys.executable, "-c", code],', + " capture_output=True,", + " text=True,", + " timeout=30,", + " check=False,", + " )", + " if result.returncode != 0:", + ' pytest.fail(f"Code failed:\\n{result.stderr}")', + "", + " checker = doctest.OutputChecker()", + f" flags = doctest.ELLIPSIS if {use_ellipsis} else 0", + " if not checker.check_output(expected.strip(), result.stdout.strip(), flags):", + " diff = checker.output_difference(", + ' doctest.Example("", expected.strip()),', + " result.stdout.strip(),", + " flags,", + " )", + ' pytest.fail(f"Output mismatch:\\n{diff}")', + ] + + def generate_test_file(file_path: Path, blocks: list[CodeBlock]) -> str: """Generate a complete pytest test file for a markdown file.""" file_stem = _sanitize_name(file_path.stem) @@ -771,7 +856,6 @@ def generate_test_file(file_path: Path, blocks: list[CodeBlock]) -> str: if needs_cuda_check: lines.extend( [ - "", "", "def _check_cuda() -> bool:", ' """Return True if CUDA toolkit and cupy are available."""', @@ -813,13 +897,12 @@ def generate_test_file(file_path: Path, blocks: list[CodeBlock]) -> str: if needs_cuda_rust_check: lines.extend( [ - "", "", "def _check_cuda_rust() -> bool:", - ' """Return True if CUDA Rust bindings (pecos_rslib_cuda) are available."""', + ' """Return True if CUDA Rust simulators can initialize."""', " try:", - " from pecos_rslib_cuda import is_cuquantum_available", - " return is_cuquantum_available()", + " from pecos_rslib_cuda import is_custabilizer_usable, is_custatevec_usable", + " return is_custatevec_usable() and is_custabilizer_usable()", " except ImportError:", " return False", "", @@ -916,7 +999,7 @@ def cuda_check() -> bool: @pytest.fixture(autouse=True) -def restore_cwd(): # noqa: ANN201 +def restore_cwd(): """Restore the current working directory after each test. Some tests (e.g., WASM examples) change the working directory, @@ -1252,6 +1335,13 @@ def main() -> None: if not pytest_blocks: continue + # Skip file entirely if every block has an unconditional skip marker + # (e.g. document-level ). No point generating a test file + # full of skipped tests — it just adds noise to pytest output. + if all(b.skip for b in pytest_blocks): + total_skipped += len(pytest_blocks) + continue + # Count skipped blocks total_skipped += sum( 1 diff --git a/scripts/docs/test_working_examples.py b/scripts/docs/test_working_examples.py index f7b37f6e0..3f80e22d0 100755 --- a/scripts/docs/test_working_examples.py +++ b/scripts/docs/test_working_examples.py @@ -26,8 +26,8 @@ import tempfile from pathlib import Path -# Import shared extraction function from test_code_examples -from test_code_examples import extract_code_blocks +# Import shared functions from test_code_examples +from test_code_examples import _uses_guppy_decorator, extract_code_blocks # Files to test (relative to the docs directory) TEST_FILES = [ @@ -52,15 +52,36 @@ def test_python_block( print(f"Testing Python block #{block_number} from {file_path}...") try: - # Execute the code block and capture output - result = subprocess.run( - [sys.executable, "-c", code_block], - capture_output=True, - text=True, - timeout=30, - check=False, - shell=False, - ) + # Guppy code needs to be in a file for inspect.getsourcelines() to work + if _uses_guppy_decorator(code_block): + with tempfile.NamedTemporaryFile( + mode="w", + suffix=".py", + delete=False, + encoding="utf-8", + ) as f: + f.write(code_block) + temp_path = f.name + try: + result = subprocess.run( + [sys.executable, temp_path], + capture_output=True, + text=True, + timeout=60, + check=False, + shell=False, + ) + finally: + Path(temp_path).unlink(missing_ok=True) + else: + result = subprocess.run( + [sys.executable, "-c", code_block], + capture_output=True, + text=True, + timeout=30, + check=False, + shell=False, + ) if result.returncode != 0: print(f"FAIL: Error in Python block #{block_number} from {file_path}:") diff --git a/scripts/native_bench/bench_pecos/Cargo.lock b/scripts/native_bench/bench_pecos/Cargo.lock index 02bd8a052..23fa1c190 100644 --- a/scripts/native_bench/bench_pecos/Cargo.lock +++ b/scripts/native_bench/bench_pecos/Cargo.lock @@ -2081,6 +2081,8 @@ dependencies = [ "num-complex", "pecos-core", "pecos-num", + "pecos-random", + "serde_json", "smallvec", ] @@ -2098,6 +2100,7 @@ dependencies = [ name = "pecos-simulators" version = "0.2.0-dev.0" dependencies = [ + "nalgebra", "num-complex", "pecos-core", "pecos-quantum", @@ -2664,9 +2667,9 @@ checksum = "f87165f0995f63a9fbeea62b64d10b4d9d8e78ec6d7d51fb2125fda7bb36788f" [[package]] name = "rustls-webpki" -version = "0.103.11" +version = "0.103.13" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "20a6af516fea4b20eccceaf166e8aa666ac996208e8a644ce3ef5aa783bc7cd4" +checksum = "61c429a8649f110dddef65e2a5ad240f747e85f7758a6bccc7e5777bd33f756e" dependencies = [ "aws-lc-rs", "ring", diff --git a/uv.lock b/uv.lock index 4ea9dd070..dac8bd4a5 100644 --- a/uv.lock +++ b/uv.lock @@ -3,11 +3,9 @@ revision = 3 requires-python = ">=3.10" resolution-markers = [ "python_full_version >= '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", - "python_full_version >= '3.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", - "python_full_version == '3.11.*' 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.12' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin')", - "(python_full_version == '3.11.*' and platform_machine != 'x86_64') or (python_full_version == '3.11.*' 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')", "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')", ] @@ -161,16 +159,15 @@ wheels = [ [[package]] name = "backrefs" -version = "6.2" +version = "7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/4e/a6/e325ec73b638d3ede4421b5445d4a0b8b219481826cc079d510100af356c/backrefs-6.2.tar.gz", hash = "sha256:f44ff4d48808b243b6c0cdc6231e22195c32f77046018141556c66f8bab72a49", size = 7012303, upload-time = "2026-02-16T19:10:15.828Z" } +sdist = { url = "https://files.pythonhosted.org/packages/5e/a7/a7dd63622beef68cc0d3c3c36d472e143dd95443d5ebf14cd1a5b4dfbf11/backrefs-7.0.tar.gz", hash = "sha256:4989bb9e1e99eb23647c7160ed51fb21d0b41b5d200f2d3017da41e023097e82", size = 7012453, upload-time = "2026-04-28T16:28:04.215Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/1b/39/3765df263e08a4df37f4f43cb5aa3c6c17a4bdd42ecfe841e04c26037171/backrefs-6.2-py310-none-any.whl", hash = "sha256:0fdc7b012420b6b144410342caeb8adc54c6866cf12064abc9bb211302e496f8", size = 381075, upload-time = "2026-02-16T19:10:04.322Z" }, - { url = "https://files.pythonhosted.org/packages/0f/f0/35240571e1b67ffb19dafb29ab34150b6f59f93f717b041082cdb1bfceb1/backrefs-6.2-py311-none-any.whl", hash = "sha256:08aa7fae530c6b2361d7bdcbda1a7c454e330cc9dbcd03f5c23205e430e5c3be", size = 392874, upload-time = "2026-02-16T19:10:06.314Z" }, - { url = "https://files.pythonhosted.org/packages/e3/63/77e8c9745b4d227cce9f5e0a6f68041278c5f9b18588b35905f5f19c1beb/backrefs-6.2-py312-none-any.whl", hash = "sha256:c3f4b9cb2af8cda0d87ab4f57800b57b95428488477be164dd2b47be54db0c90", size = 398787, upload-time = "2026-02-16T19:10:08.274Z" }, - { url = "https://files.pythonhosted.org/packages/c5/71/c754b1737ad99102e03fa3235acb6cb6d3ac9d6f596cbc3e5f236705abd8/backrefs-6.2-py313-none-any.whl", hash = "sha256:12df81596ab511f783b7d87c043ce26bc5b0288cf3bb03610fe76b8189282b2b", size = 400747, upload-time = "2026-02-16T19:10:09.791Z" }, - { url = "https://files.pythonhosted.org/packages/af/75/be12ba31a6eb20dccef2320cd8ccb3f7d9013b68ba4c70156259fee9e409/backrefs-6.2-py314-none-any.whl", hash = "sha256:e5f805ae09819caa1aa0623b4a83790e7028604aa2b8c73ba602c4454e665de7", size = 412602, upload-time = "2026-02-16T19:10:12.317Z" }, - { url = "https://files.pythonhosted.org/packages/21/f8/d02f650c47d05034dcd6f9c8cf94f39598b7a89c00ecda0ecb2911bc27e9/backrefs-6.2-py39-none-any.whl", hash = "sha256:664e33cd88c6840b7625b826ecf2555f32d491800900f5a541f772c485f7cda7", size = 381077, upload-time = "2026-02-16T19:10:13.74Z" }, + { url = "https://files.pythonhosted.org/packages/d4/39/39a31d7eae729ea14ed10c3ccef79371197177b9355a86cb3525709e8502/backrefs-7.0-py310-none-any.whl", hash = "sha256:b57cd227ea556b0aed3dc9b8da4628db4eabc0402c6d7fcfc69283a93955f7e9", size = 380824, upload-time = "2026-04-28T16:27:55.647Z" }, + { url = "https://files.pythonhosted.org/packages/c9/b5/9302644225ba7dfa934a2ff2b9c7bb85701313a90dddb3dfaf693fa5bae2/backrefs-7.0-py311-none-any.whl", hash = "sha256:a0fa7360c63509e9e077e174ef4e6d3c21c8db94189b9d957289ae6d794b9475", size = 392626, upload-time = "2026-04-28T16:27:57.42Z" }, + { url = "https://files.pythonhosted.org/packages/36/da/87912ddec6e06feffbaa3d7aa18fc6352bee2e8f1fee185d7d1690f8f4e8/backrefs-7.0-py312-none-any.whl", hash = "sha256:ca42ce6a49ace3d75684dfa9937f3373902a63284ecb385ce36d15e5dcb41c12", size = 398537, upload-time = "2026-04-28T16:27:58.913Z" }, + { url = "https://files.pythonhosted.org/packages/00/bb/90ba423612b6aa0adccc6b1874bcd4a9b44b660c0c16f346611e00f64ac3/backrefs-7.0-py313-none-any.whl", hash = "sha256:f2c52955d631b9e1ac4cd56209f0a3a946d592b98e7790e77699339ae01c102a", size = 400491, upload-time = "2026-04-28T16:28:00.928Z" }, + { url = "https://files.pythonhosted.org/packages/3e/5c/fb93d3092640a24dfb7bd7727a24016d7c01774ca013e60efd3f683c8002/backrefs-7.0-py314-none-any.whl", hash = "sha256:a6448b28180e3ca01134c9cf09dcebafad8531072e09903c5451748a05f24bc9", size = 412349, upload-time = "2026-04-28T16:28:02.412Z" }, ] [[package]] @@ -249,11 +246,11 @@ css = [ [[package]] name = "certifi" -version = "2026.2.25" +version = "2026.4.22" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/af/2d/7bf41579a8986e348fa033a31cdd0e4121114f6bce2457e8876010b092dd/certifi-2026.2.25.tar.gz", hash = "sha256:e887ab5cee78ea814d3472169153c2d12cd43b14bd03329a39a9c6e2e80bfba7", size = 155029, upload-time = "2026-02-25T02:54:17.342Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/9a/3c/c17fb3ca2d9c3acff52e30b309f538586f9f5b9c9cf454f3845fc9af4881/certifi-2026.2.25-py3-none-any.whl", hash = "sha256:027692e4402ad994f1c42e52a4997a9763c646b73e4096e4d5d6db8af1d6f0fa", size = 153684, upload-time = "2026-02-25T02:54:15.766Z" }, + { 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" }, ] [[package]] @@ -454,14 +451,14 @@ wheels = [ [[package]] name = "click" -version = "8.3.2" +version = "8.3.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "colorama", marker = "sys_platform == 'win32'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/57/75/31212c6bf2503fdf920d87fee5d7a86a2e3bcf444984126f13d8e4016804/click-8.3.2.tar.gz", hash = "sha256:14162b8b3b3550a7d479eafa77dfd3c38d9dc8951f6f69c78913a8f9a7540fd5", size = 302856, upload-time = "2026-04-03T19:14:45.118Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e4/20/71885d8b97d4f3dde17b1fdb92dbd4908b00541c5a3379787137285f602e/click-8.3.2-py3-none-any.whl", hash = "sha256:1924d2c27c5653561cd2cae4548d1406039cb79b858b747cfea24924bbc1616d", size = 108379, upload-time = "2026-04-03T19:14:43.505Z" }, + { 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" }, ] [[package]] @@ -559,11 +556,9 @@ version = "1.3.3" 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.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", - "python_full_version == '3.11.*' 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.12' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin')", - "(python_full_version == '3.11.*' and platform_machine != 'x86_64') or (python_full_version == '3.11.*' 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')", ] dependencies = [ { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -645,115 +640,115 @@ wheels = [ [[package]] name = "coverage" -version = "7.13.5" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/9d/e0/70553e3000e345daff267cec284ce4cbf3fc141b6da229ac52775b5428f1/coverage-7.13.5.tar.gz", hash = "sha256:c81f6515c4c40141f83f502b07bbfa5c240ba25bbe73da7b33f1e5b6120ff179", size = 915967, upload-time = "2026-03-17T10:33:18.341Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/69/33/e8c48488c29a73fd089f9d71f9653c1be7478f2ad6b5bc870db11a55d23d/coverage-7.13.5-cp310-cp310-macosx_10_9_x86_64.whl", hash = "sha256:e0723d2c96324561b9aa76fb982406e11d93cdb388a7a7da2b16e04719cf7ca5", size = 219255, upload-time = "2026-03-17T10:29:51.081Z" }, - { url = "https://files.pythonhosted.org/packages/da/bd/b0ebe9f677d7f4b74a3e115eec7ddd4bcf892074963a00d91e8b164a6386/coverage-7.13.5-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:52f444e86475992506b32d4e5ca55c24fc88d73bcbda0e9745095b28ef4dc0cf", size = 219772, upload-time = "2026-03-17T10:29:52.867Z" }, - { url = "https://files.pythonhosted.org/packages/48/cc/5cb9502f4e01972f54eedd48218bb203fe81e294be606a2bc93970208013/coverage-7.13.5-cp310-cp310-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:704de6328e3d612a8f6c07000a878ff38181ec3263d5a11da1db294fa6a9bdf8", size = 246532, upload-time = "2026-03-17T10:29:54.688Z" }, - { url = "https://files.pythonhosted.org/packages/7d/d8/3217636d86c7e7b12e126e4f30ef1581047da73140614523af7495ed5f2d/coverage-7.13.5-cp310-cp310-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:a1a6d79a14e1ec1832cabc833898636ad5f3754a678ef8bb4908515208bf84f4", size = 248333, upload-time = "2026-03-17T10:29:56.221Z" }, - { url = "https://files.pythonhosted.org/packages/2b/30/2002ac6729ba2d4357438e2ed3c447ad8562866c8c63fc16f6dfc33afe56/coverage-7.13.5-cp310-cp310-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:79060214983769c7ba3f0cee10b54c97609dca4d478fa1aa32b914480fd5738d", size = 250211, upload-time = "2026-03-17T10:29:57.938Z" }, - { url = "https://files.pythonhosted.org/packages/6c/85/552496626d6b9359eb0e2f86f920037c9cbfba09b24d914c6e1528155f7d/coverage-7.13.5-cp310-cp310-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:356e76b46783a98c2a2fe81ec79df4883a1e62895ea952968fb253c114e7f930", size = 252125, upload-time = "2026-03-17T10:29:59.388Z" }, - { url = "https://files.pythonhosted.org/packages/44/21/40256eabdcbccdb6acf6b381b3016a154399a75fe39d406f790ae84d1f3c/coverage-7.13.5-cp310-cp310-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0cef0cdec915d11254a7f549c1170afecce708d30610c6abdded1f74e581666d", size = 247219, upload-time = "2026-03-17T10:30:01.199Z" }, - { url = "https://files.pythonhosted.org/packages/b1/e8/96e2a6c3f21a0ea77d7830b254a1542d0328acc8d7bdf6a284ba7e529f77/coverage-7.13.5-cp310-cp310-musllinux_1_2_aarch64.whl", hash = "sha256:dc022073d063b25a402454e5712ef9e007113e3a676b96c5f29b2bda29352f40", size = 248248, upload-time = "2026-03-17T10:30:03.317Z" }, - { url = "https://files.pythonhosted.org/packages/da/ba/8477f549e554827da390ec659f3c38e4b6d95470f4daafc2d8ff94eaa9c2/coverage-7.13.5-cp310-cp310-musllinux_1_2_i686.whl", hash = "sha256:9b74db26dfea4f4e50d48a4602207cd1e78be33182bc9cbf22da94f332f99878", size = 246254, upload-time = "2026-03-17T10:30:04.832Z" }, - { url = "https://files.pythonhosted.org/packages/55/59/bc22aef0e6aa179d5b1b001e8b3654785e9adf27ef24c93dc4228ebd5d68/coverage-7.13.5-cp310-cp310-musllinux_1_2_ppc64le.whl", hash = "sha256:ad146744ca4fd09b50c482650e3c1b1f4dfa1d4792e0a04a369c7f23336f0400", size = 250067, upload-time = "2026-03-17T10:30:06.535Z" }, - { url = "https://files.pythonhosted.org/packages/de/1b/c6a023a160806a5137dca53468fd97530d6acad24a22003b1578a9c2e429/coverage-7.13.5-cp310-cp310-musllinux_1_2_riscv64.whl", hash = "sha256:c555b48be1853fe3997c11c4bd521cdd9a9612352de01fa4508f16ec341e6fe0", size = 246521, upload-time = "2026-03-17T10:30:08.486Z" }, - { url = "https://files.pythonhosted.org/packages/2d/3f/3532c85a55aa2f899fa17c186f831cfa1aa434d88ff792a709636f64130e/coverage-7.13.5-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:7034b5c56a58ae5e85f23949d52c14aca2cfc6848a31764995b7de88f13a1ea0", size = 247126, upload-time = "2026-03-17T10:30:09.966Z" }, - { url = "https://files.pythonhosted.org/packages/aa/2e/b9d56af4a24ef45dfbcda88e06870cb7d57b2b0bfa3a888d79b4c8debd76/coverage-7.13.5-cp310-cp310-win32.whl", hash = "sha256:eb7fdf1ef130660e7415e0253a01a7d5a88c9c4d158bcf75cbbd922fd65a5b58", size = 221860, upload-time = "2026-03-17T10:30:11.393Z" }, - { url = "https://files.pythonhosted.org/packages/9f/cc/d938417e7a4d7f0433ad4edee8bb2acdc60dc7ac5af19e2a07a048ecbee3/coverage-7.13.5-cp310-cp310-win_amd64.whl", hash = "sha256:3e1bb5f6c78feeb1be3475789b14a0f0a5b47d505bfc7267126ccbd50289999e", size = 222788, upload-time = "2026-03-17T10:30:12.886Z" }, - { url = "https://files.pythonhosted.org/packages/4b/37/d24c8f8220ff07b839b2c043ea4903a33b0f455abe673ae3c03bbdb7f212/coverage-7.13.5-cp311-cp311-macosx_10_9_x86_64.whl", hash = "sha256:66a80c616f80181f4d643b0f9e709d97bcea413ecd9631e1dedc7401c8e6695d", size = 219381, upload-time = "2026-03-17T10:30:14.68Z" }, - { url = "https://files.pythonhosted.org/packages/35/8b/cd129b0ca4afe886a6ce9d183c44d8301acbd4ef248622e7c49a23145605/coverage-7.13.5-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:145ede53ccbafb297c1c9287f788d1bc3efd6c900da23bf6931b09eafc931587", size = 219880, upload-time = "2026-03-17T10:30:16.231Z" }, - { url = "https://files.pythonhosted.org/packages/55/2f/e0e5b237bffdb5d6c530ce87cc1d413a5b7d7dfd60fb067ad6d254c35c76/coverage-7.13.5-cp311-cp311-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:0672854dc733c342fa3e957e0605256d2bf5934feeac328da9e0b5449634a642", size = 250303, upload-time = "2026-03-17T10:30:17.748Z" }, - { url = "https://files.pythonhosted.org/packages/92/be/b1afb692be85b947f3401375851484496134c5554e67e822c35f28bf2fbc/coverage-7.13.5-cp311-cp311-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:ec10e2a42b41c923c2209b846126c6582db5e43a33157e9870ba9fb70dc7854b", size = 252218, upload-time = "2026-03-17T10:30:19.804Z" }, - { url = "https://files.pythonhosted.org/packages/da/69/2f47bb6fa1b8d1e3e5d0c4be8ccb4313c63d742476a619418f85740d597b/coverage-7.13.5-cp311-cp311-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:be3d4bbad9d4b037791794ddeedd7d64a56f5933a2c1373e18e9e568b9141686", size = 254326, upload-time = "2026-03-17T10:30:21.321Z" }, - { url = "https://files.pythonhosted.org/packages/d5/d0/79db81da58965bd29dabc8f4ad2a2af70611a57cba9d1ec006f072f30a54/coverage-7.13.5-cp311-cp311-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:4d2afbc5cc54d286bfb54541aa50b64cdb07a718227168c87b9e2fb8f25e1743", size = 256267, upload-time = "2026-03-17T10:30:23.094Z" }, - { url = "https://files.pythonhosted.org/packages/e5/32/d0d7cc8168f91ddab44c0ce4806b969df5f5fdfdbb568eaca2dbc2a04936/coverage-7.13.5-cp311-cp311-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3ad050321264c49c2fa67bb599100456fc51d004b82534f379d16445da40fb75", size = 250430, upload-time = "2026-03-17T10:30:25.311Z" }, - { url = "https://files.pythonhosted.org/packages/4d/06/a055311d891ddbe231cd69fdd20ea4be6e3603ffebddf8704b8ca8e10a3c/coverage-7.13.5-cp311-cp311-musllinux_1_2_aarch64.whl", hash = "sha256:7300c8a6d13335b29bb76d7651c66af6bd8658517c43499f110ddc6717bfc209", size = 252017, upload-time = "2026-03-17T10:30:27.284Z" }, - { url = "https://files.pythonhosted.org/packages/d6/f6/d0fd2d21e29a657b5f77a2fe7082e1568158340dceb941954f776dce1b7b/coverage-7.13.5-cp311-cp311-musllinux_1_2_i686.whl", hash = "sha256:eb07647a5738b89baab047f14edd18ded523de60f3b30e75c2acc826f79c839a", size = 250080, upload-time = "2026-03-17T10:30:29.481Z" }, - { url = "https://files.pythonhosted.org/packages/4e/ab/0d7fb2efc2e9a5eb7ddcc6e722f834a69b454b7e6e5888c3a8567ecffb31/coverage-7.13.5-cp311-cp311-musllinux_1_2_ppc64le.whl", hash = "sha256:9adb6688e3b53adffefd4a52d72cbd8b02602bfb8f74dcd862337182fd4d1a4e", size = 253843, upload-time = "2026-03-17T10:30:31.301Z" }, - { url = "https://files.pythonhosted.org/packages/ba/6f/7467b917bbf5408610178f62a49c0ed4377bb16c1657f689cc61470da8ce/coverage-7.13.5-cp311-cp311-musllinux_1_2_riscv64.whl", hash = "sha256:7c8d4bc913dd70b93488d6c496c77f3aff5ea99a07e36a18f865bca55adef8bd", size = 249802, upload-time = "2026-03-17T10:30:33.358Z" }, - { url = "https://files.pythonhosted.org/packages/75/2c/1172fb689df92135f5bfbbd69fc83017a76d24ea2e2f3a1154007e2fb9f8/coverage-7.13.5-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:0e3c426ffc4cd952f54ee9ffbdd10345709ecc78a3ecfd796a57236bfad0b9b8", size = 250707, upload-time = "2026-03-17T10:30:35.2Z" }, - { url = "https://files.pythonhosted.org/packages/67/21/9ac389377380a07884e3b48ba7a620fcd9dbfaf1d40565facdc6b36ec9ef/coverage-7.13.5-cp311-cp311-win32.whl", hash = "sha256:259b69bb83ad9894c4b25be2528139eecba9a82646ebdda2d9db1ba28424a6bf", size = 221880, upload-time = "2026-03-17T10:30:36.775Z" }, - { url = "https://files.pythonhosted.org/packages/af/7f/4cd8a92531253f9d7c1bbecd9fa1b472907fb54446ca768c59b531248dc5/coverage-7.13.5-cp311-cp311-win_amd64.whl", hash = "sha256:258354455f4e86e3e9d0d17571d522e13b4e1e19bf0f8596bcf9476d61e7d8a9", size = 222816, upload-time = "2026-03-17T10:30:38.891Z" }, - { url = "https://files.pythonhosted.org/packages/12/a6/1d3f6155fb0010ca68eba7fe48ca6c9da7385058b77a95848710ecf189b1/coverage-7.13.5-cp311-cp311-win_arm64.whl", hash = "sha256:bff95879c33ec8da99fc9b6fe345ddb5be6414b41d6d1ad1c8f188d26f36e028", size = 221483, upload-time = "2026-03-17T10:30:40.463Z" }, - { url = "https://files.pythonhosted.org/packages/a0/c3/a396306ba7db865bf96fc1fb3b7fd29bcbf3d829df642e77b13555163cd6/coverage-7.13.5-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:460cf0114c5016fa841214ff5564aa4864f11948da9440bc97e21ad1f4ba1e01", size = 219554, upload-time = "2026-03-17T10:30:42.208Z" }, - { url = "https://files.pythonhosted.org/packages/a6/16/a68a19e5384e93f811dccc51034b1fd0b865841c390e3c931dcc4699e035/coverage-7.13.5-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:0e223ce4b4ed47f065bfb123687686512e37629be25cc63728557ae7db261422", size = 219908, upload-time = "2026-03-17T10:30:43.906Z" }, - { url = "https://files.pythonhosted.org/packages/29/72/20b917c6793af3a5ceb7fb9c50033f3ec7865f2911a1416b34a7cfa0813b/coverage-7.13.5-cp312-cp312-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:6e3370441f4513c6252bf042b9c36d22491142385049243253c7e48398a15a9f", size = 251419, upload-time = "2026-03-17T10:30:45.545Z" }, - { url = "https://files.pythonhosted.org/packages/8c/49/cd14b789536ac6a4778c453c6a2338bc0a2fb60c5a5a41b4008328b9acc1/coverage-7.13.5-cp312-cp312-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:03ccc709a17a1de074fb1d11f217342fb0d2b1582ed544f554fc9fc3f07e95f5", size = 254159, upload-time = "2026-03-17T10:30:47.204Z" }, - { url = "https://files.pythonhosted.org/packages/9d/00/7b0edcfe64e2ed4c0340dac14a52ad0f4c9bd0b8b5e531af7d55b703db7c/coverage-7.13.5-cp312-cp312-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:3f4818d065964db3c1c66dc0fbdac5ac692ecbc875555e13374fdbe7eedb4376", size = 255270, upload-time = "2026-03-17T10:30:48.812Z" }, - { url = "https://files.pythonhosted.org/packages/93/89/7ffc4ba0f5d0a55c1e84ea7cee39c9fc06af7b170513d83fbf3bbefce280/coverage-7.13.5-cp312-cp312-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:012d5319e66e9d5a218834642d6c35d265515a62f01157a45bcc036ecf947256", size = 257538, upload-time = "2026-03-17T10:30:50.77Z" }, - { url = "https://files.pythonhosted.org/packages/81/bd/73ddf85f93f7e6fa83e77ccecb6162d9415c79007b4bc124008a4995e4a7/coverage-7.13.5-cp312-cp312-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:8dd02af98971bdb956363e4827d34425cb3df19ee550ef92855b0acb9c7ce51c", size = 251821, upload-time = "2026-03-17T10:30:52.5Z" }, - { url = "https://files.pythonhosted.org/packages/a0/81/278aff4e8dec4926a0bcb9486320752811f543a3ce5b602cc7a29978d073/coverage-7.13.5-cp312-cp312-musllinux_1_2_aarch64.whl", hash = "sha256:f08fd75c50a760c7eb068ae823777268daaf16a80b918fa58eea888f8e3919f5", size = 253191, upload-time = "2026-03-17T10:30:54.543Z" }, - { url = "https://files.pythonhosted.org/packages/70/ee/fe1621488e2e0a58d7e94c4800f0d96f79671553488d401a612bebae324b/coverage-7.13.5-cp312-cp312-musllinux_1_2_i686.whl", hash = "sha256:843ea8643cf967d1ac7e8ecd4bb00c99135adf4816c0c0593fdcc47b597fcf09", size = 251337, upload-time = "2026-03-17T10:30:56.663Z" }, - { url = "https://files.pythonhosted.org/packages/37/a6/f79fb37aa104b562207cc23cb5711ab6793608e246cae1e93f26b2236ed9/coverage-7.13.5-cp312-cp312-musllinux_1_2_ppc64le.whl", hash = "sha256:9d44d7aa963820b1b971dbecd90bfe5fe8f81cff79787eb6cca15750bd2f79b9", size = 255404, upload-time = "2026-03-17T10:30:58.427Z" }, - { url = "https://files.pythonhosted.org/packages/75/f0/ed15262a58ec81ce457ceb717b7f78752a1713556b19081b76e90896e8d4/coverage-7.13.5-cp312-cp312-musllinux_1_2_riscv64.whl", hash = "sha256:7132bed4bd7b836200c591410ae7d97bf7ae8be6fc87d160b2bd881df929e7bf", size = 250903, upload-time = "2026-03-17T10:31:00.093Z" }, - { url = "https://files.pythonhosted.org/packages/0f/e9/9129958f20e7e9d4d56d51d42ccf708d15cac355ff4ac6e736e97a9393d2/coverage-7.13.5-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:a698e363641b98843c517817db75373c83254781426e94ada3197cabbc2c919c", size = 252780, upload-time = "2026-03-17T10:31:01.916Z" }, - { url = "https://files.pythonhosted.org/packages/a4/d7/0ad9b15812d81272db94379fe4c6df8fd17781cc7671fdfa30c76ba5ff7b/coverage-7.13.5-cp312-cp312-win32.whl", hash = "sha256:bdba0a6b8812e8c7df002d908a9a2ea3c36e92611b5708633c50869e6d922fdf", size = 222093, upload-time = "2026-03-17T10:31:03.642Z" }, - { url = "https://files.pythonhosted.org/packages/29/3d/821a9a5799fac2556bcf0bd37a70d1d11fa9e49784b6d22e92e8b2f85f18/coverage-7.13.5-cp312-cp312-win_amd64.whl", hash = "sha256:d2c87e0c473a10bffe991502eac389220533024c8082ec1ce849f4218dded810", size = 222900, upload-time = "2026-03-17T10:31:05.651Z" }, - { url = "https://files.pythonhosted.org/packages/d4/fa/2238c2ad08e35cf4f020ea721f717e09ec3152aea75d191a7faf3ef009a8/coverage-7.13.5-cp312-cp312-win_arm64.whl", hash = "sha256:bf69236a9a81bdca3bff53796237aab096cdbf8d78a66ad61e992d9dac7eb2de", size = 221515, upload-time = "2026-03-17T10:31:07.293Z" }, - { url = "https://files.pythonhosted.org/packages/74/8c/74fedc9663dcf168b0a059d4ea756ecae4da77a489048f94b5f512a8d0b3/coverage-7.13.5-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:5ec4af212df513e399cf11610cc27063f1586419e814755ab362e50a85ea69c1", size = 219576, upload-time = "2026-03-17T10:31:09.045Z" }, - { url = "https://files.pythonhosted.org/packages/0c/c9/44fb661c55062f0818a6ffd2685c67aa30816200d5f2817543717d4b92eb/coverage-7.13.5-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:941617e518602e2d64942c88ec8499f7fbd49d3f6c4327d3a71d43a1973032f3", size = 219942, upload-time = "2026-03-17T10:31:10.708Z" }, - { url = "https://files.pythonhosted.org/packages/5f/13/93419671cee82b780bab7ea96b67c8ef448f5f295f36bf5031154ec9a790/coverage-7.13.5-cp313-cp313-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:da305e9937617ee95c2e39d8ff9f040e0487cbf1ac174f777ed5eddd7a7c1f26", size = 250935, upload-time = "2026-03-17T10:31:12.392Z" }, - { url = "https://files.pythonhosted.org/packages/ac/68/1666e3a4462f8202d836920114fa7a5ee9275d1fa45366d336c551a162dd/coverage-7.13.5-cp313-cp313-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:78e696e1cc714e57e8b25760b33a8b1026b7048d270140d25dafe1b0a1ee05a3", size = 253541, upload-time = "2026-03-17T10:31:14.247Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5e/3ee3b835647be646dcf3c65a7c6c18f87c27326a858f72ab22c12730773d/coverage-7.13.5-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:02ca0eed225b2ff301c474aeeeae27d26e2537942aa0f87491d3e147e784a82b", size = 254780, upload-time = "2026-03-17T10:31:16.193Z" }, - { url = "https://files.pythonhosted.org/packages/44/b3/cb5bd1a04cfcc49ede6cd8409d80bee17661167686741e041abc7ee1b9a9/coverage-7.13.5-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:04690832cbea4e4663d9149e05dba142546ca05cb1848816760e7f58285c970a", size = 256912, upload-time = "2026-03-17T10:31:17.89Z" }, - { url = "https://files.pythonhosted.org/packages/1b/66/c1dceb7b9714473800b075f5c8a84f4588f887a90eb8645282031676e242/coverage-7.13.5-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:0590e44dd2745c696a778f7bab6aa95256de2cbc8b8cff4f7db8ff09813d6969", size = 251165, upload-time = "2026-03-17T10:31:19.605Z" }, - { url = "https://files.pythonhosted.org/packages/b7/62/5502b73b97aa2e53ea22a39cf8649ff44827bef76d90bf638777daa27a9d/coverage-7.13.5-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:d7cfad2d6d81dd298ab6b89fe72c3b7b05ec7544bdda3b707ddaecff8d25c161", size = 252908, upload-time = "2026-03-17T10:31:21.312Z" }, - { url = "https://files.pythonhosted.org/packages/7d/37/7792c2d69854397ca77a55c4646e5897c467928b0e27f2d235d83b5d08c6/coverage-7.13.5-cp313-cp313-musllinux_1_2_i686.whl", hash = "sha256:e092b9499de38ae0fbfbc603a74660eb6ff3e869e507b50d85a13b6db9863e15", size = 250873, upload-time = "2026-03-17T10:31:23.565Z" }, - { url = "https://files.pythonhosted.org/packages/a3/23/bc866fb6163be52a8a9e5d708ba0d3b1283c12158cefca0a8bbb6e247a43/coverage-7.13.5-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:48c39bc4a04d983a54a705a6389512883d4a3b9862991b3617d547940e9f52b1", size = 255030, upload-time = "2026-03-17T10:31:25.58Z" }, - { url = "https://files.pythonhosted.org/packages/7d/8b/ef67e1c222ef49860701d346b8bbb70881bef283bd5f6cbba68a39a086c7/coverage-7.13.5-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:2d3807015f138ffea1ed9afeeb8624fd781703f2858b62a8dd8da5a0994c57b6", size = 250694, upload-time = "2026-03-17T10:31:27.316Z" }, - { url = "https://files.pythonhosted.org/packages/46/0d/866d1f74f0acddbb906db212e096dee77a8e2158ca5e6bb44729f9d93298/coverage-7.13.5-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:ee2aa19e03161671ec964004fb74b2257805d9710bf14a5c704558b9d8dbaf17", size = 252469, upload-time = "2026-03-17T10:31:29.472Z" }, - { url = "https://files.pythonhosted.org/packages/7a/f5/be742fec31118f02ce42b21c6af187ad6a344fed546b56ca60caacc6a9a0/coverage-7.13.5-cp313-cp313-win32.whl", hash = "sha256:ce1998c0483007608c8382f4ff50164bfc5bd07a2246dd272aa4043b75e61e85", size = 222112, upload-time = "2026-03-17T10:31:31.526Z" }, - { url = "https://files.pythonhosted.org/packages/66/40/7732d648ab9d069a46e686043241f01206348e2bbf128daea85be4d6414b/coverage-7.13.5-cp313-cp313-win_amd64.whl", hash = "sha256:631efb83f01569670a5e866ceb80fe483e7c159fac6f167e6571522636104a0b", size = 222923, upload-time = "2026-03-17T10:31:33.633Z" }, - { url = "https://files.pythonhosted.org/packages/48/af/fea819c12a095781f6ccd504890aaddaf88b8fab263c4940e82c7b770124/coverage-7.13.5-cp313-cp313-win_arm64.whl", hash = "sha256:f4cd16206ad171cbc2470dbea9103cf9a7607d5fe8c242fdf1edf36174020664", size = 221540, upload-time = "2026-03-17T10:31:35.445Z" }, - { url = "https://files.pythonhosted.org/packages/23/d2/17879af479df7fbbd44bd528a31692a48f6b25055d16482fdf5cdb633805/coverage-7.13.5-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:0428cbef5783ad91fe240f673cc1f76b25e74bbfe1a13115e4aa30d3f538162d", size = 220262, upload-time = "2026-03-17T10:31:37.184Z" }, - { url = "https://files.pythonhosted.org/packages/5b/4c/d20e554f988c8f91d6a02c5118f9abbbf73a8768a3048cb4962230d5743f/coverage-7.13.5-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:e0b216a19534b2427cc201a26c25da4a48633f29a487c61258643e89d28200c0", size = 220617, upload-time = "2026-03-17T10:31:39.245Z" }, - { url = "https://files.pythonhosted.org/packages/29/9c/f9f5277b95184f764b24e7231e166dfdb5780a46d408a2ac665969416d61/coverage-7.13.5-cp313-cp313t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:972a9cd27894afe4bc2b1480107054e062df08e671df7c2f18c205e805ccd806", size = 261912, upload-time = "2026-03-17T10:31:41.324Z" }, - { url = "https://files.pythonhosted.org/packages/d5/f6/7f1ab39393eeb50cfe4747ae8ef0e4fc564b989225aa1152e13a180d74f8/coverage-7.13.5-cp313-cp313t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:4b59148601efcd2bac8c4dbf1f0ad6391693ccf7a74b8205781751637076aee3", size = 263987, upload-time = "2026-03-17T10:31:43.724Z" }, - { url = "https://files.pythonhosted.org/packages/a0/d7/62c084fb489ed9c6fbdf57e006752e7c516ea46fd690e5ed8b8617c7d52e/coverage-7.13.5-cp313-cp313t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:505d7083c8b0c87a8fa8c07370c285847c1f77739b22e299ad75a6af6c32c5c9", size = 266416, upload-time = "2026-03-17T10:31:45.769Z" }, - { url = "https://files.pythonhosted.org/packages/a9/f6/df63d8660e1a0bff6125947afda112a0502736f470d62ca68b288ea762d8/coverage-7.13.5-cp313-cp313t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:60365289c3741e4db327e7baff2a4aaacf22f788e80fa4683393891b70a89fbd", size = 267558, upload-time = "2026-03-17T10:31:48.293Z" }, - { url = "https://files.pythonhosted.org/packages/5b/02/353ca81d36779bd108f6d384425f7139ac3c58c750dcfaafe5d0bee6436b/coverage-7.13.5-cp313-cp313t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:1b88c69c8ef5d4b6fe7dea66d6636056a0f6a7527c440e890cf9259011f5e606", size = 261163, upload-time = "2026-03-17T10:31:50.125Z" }, - { url = "https://files.pythonhosted.org/packages/2c/16/2e79106d5749bcaf3aee6d309123548e3276517cd7851faa8da213bc61bf/coverage-7.13.5-cp313-cp313t-musllinux_1_2_aarch64.whl", hash = "sha256:5b13955d31d1633cf9376908089b7cebe7d15ddad7aeaabcbe969a595a97e95e", size = 263981, upload-time = "2026-03-17T10:31:51.961Z" }, - { url = "https://files.pythonhosted.org/packages/29/c7/c29e0c59ffa6942030ae6f50b88ae49988e7e8da06de7ecdbf49c6d4feae/coverage-7.13.5-cp313-cp313t-musllinux_1_2_i686.whl", hash = "sha256:f70c9ab2595c56f81a89620e22899eea8b212a4041bd728ac6f4a28bf5d3ddd0", size = 261604, upload-time = "2026-03-17T10:31:53.872Z" }, - { url = "https://files.pythonhosted.org/packages/40/48/097cdc3db342f34006a308ab41c3a7c11c3f0d84750d340f45d88a782e00/coverage-7.13.5-cp313-cp313t-musllinux_1_2_ppc64le.whl", hash = "sha256:084b84a8c63e8d6fc7e3931b316a9bcafca1458d753c539db82d31ed20091a87", size = 265321, upload-time = "2026-03-17T10:31:55.997Z" }, - { url = "https://files.pythonhosted.org/packages/bb/1f/4994af354689e14fd03a75f8ec85a9a68d94e0188bbdab3fc1516b55e512/coverage-7.13.5-cp313-cp313t-musllinux_1_2_riscv64.whl", hash = "sha256:ad14385487393e386e2ea988b09d62dd42c397662ac2dabc3832d71253eee479", size = 260502, upload-time = "2026-03-17T10:31:58.308Z" }, - { url = "https://files.pythonhosted.org/packages/22/c6/9bb9ef55903e628033560885f5c31aa227e46878118b63ab15dc7ba87797/coverage-7.13.5-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:7f2c47b36fe7709a6e83bfadf4eefb90bd25fbe4014d715224c4316f808e59a2", size = 262688, upload-time = "2026-03-17T10:32:00.141Z" }, - { url = "https://files.pythonhosted.org/packages/14/4f/f5df9007e50b15e53e01edea486814783a7f019893733d9e4d6caad75557/coverage-7.13.5-cp313-cp313t-win32.whl", hash = "sha256:67e9bc5449801fad0e5dff329499fb090ba4c5800b86805c80617b4e29809b2a", size = 222788, upload-time = "2026-03-17T10:32:02.246Z" }, - { url = "https://files.pythonhosted.org/packages/e1/98/aa7fccaa97d0f3192bec013c4e6fd6d294a6ed44b640e6bb61f479e00ed5/coverage-7.13.5-cp313-cp313t-win_amd64.whl", hash = "sha256:da86cdcf10d2519e10cabb8ac2de03da1bcb6e4853790b7fbd48523332e3a819", size = 223851, upload-time = "2026-03-17T10:32:04.416Z" }, - { url = "https://files.pythonhosted.org/packages/3d/8b/e5c469f7352651e5f013198e9e21f97510b23de957dd06a84071683b4b60/coverage-7.13.5-cp313-cp313t-win_arm64.whl", hash = "sha256:0ecf12ecb326fe2c339d93fc131816f3a7367d223db37817208905c89bded911", size = 222104, upload-time = "2026-03-17T10:32:06.65Z" }, - { url = "https://files.pythonhosted.org/packages/8e/77/39703f0d1d4b478bfd30191d3c14f53caf596fac00efb3f8f6ee23646439/coverage-7.13.5-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:fbabfaceaeb587e16f7008f7795cd80d20ec548dc7f94fbb0d4ec2e038ce563f", size = 219621, upload-time = "2026-03-17T10:32:08.589Z" }, - { url = "https://files.pythonhosted.org/packages/e2/3e/51dff36d99ae14639a133d9b164d63e628532e2974d8b1edb99dd1ebc733/coverage-7.13.5-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:9bb2a28101a443669a423b665939381084412b81c3f8c0fcfbac57f4e30b5b8e", size = 219953, upload-time = "2026-03-17T10:32:10.507Z" }, - { url = "https://files.pythonhosted.org/packages/6a/6c/1f1917b01eb647c2f2adc9962bd66c79eb978951cab61bdc1acab3290c07/coverage-7.13.5-cp314-cp314-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:bd3a2fbc1c6cccb3c5106140d87cc6a8715110373ef42b63cf5aea29df8c217a", size = 250992, upload-time = "2026-03-17T10:32:12.41Z" }, - { url = "https://files.pythonhosted.org/packages/22/e5/06b1f88f42a5a99df42ce61208bdec3bddb3d261412874280a19796fc09c/coverage-7.13.5-cp314-cp314-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:6c36ddb64ed9d7e496028d1d00dfec3e428e0aabf4006583bb1839958d280510", size = 253503, upload-time = "2026-03-17T10:32:14.449Z" }, - { url = "https://files.pythonhosted.org/packages/80/28/2a148a51e5907e504fa7b85490277734e6771d8844ebcc48764a15e28155/coverage-7.13.5-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:380e8e9084d8eb38db3a9176a1a4f3c0082c3806fa0dc882d1d87abc3c789247", size = 254852, upload-time = "2026-03-17T10:32:16.56Z" }, - { url = "https://files.pythonhosted.org/packages/61/77/50e8d3d85cc0b7ebe09f30f151d670e302c7ff4a1bf6243f71dd8b0981fa/coverage-7.13.5-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e808af52a0513762df4d945ea164a24b37f2f518cbe97e03deaa0ee66139b4d6", size = 257161, upload-time = "2026-03-17T10:32:19.004Z" }, - { url = "https://files.pythonhosted.org/packages/3b/c4/b5fd1d4b7bf8d0e75d997afd3925c59ba629fc8616f1b3aae7605132e256/coverage-7.13.5-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e301d30dd7e95ae068671d746ba8c34e945a82682e62918e41b2679acd2051a0", size = 251021, upload-time = "2026-03-17T10:32:21.344Z" }, - { url = "https://files.pythonhosted.org/packages/f8/66/6ea21f910e92d69ef0b1c3346ea5922a51bad4446c9126db2ae96ee24c4c/coverage-7.13.5-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:800bc829053c80d240a687ceeb927a94fd108bbdc68dfbe505d0d75ab578a882", size = 252858, upload-time = "2026-03-17T10:32:23.506Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ea/879c83cb5d61aa2a35fb80e72715e92672daef8191b84911a643f533840c/coverage-7.13.5-cp314-cp314-musllinux_1_2_i686.whl", hash = "sha256:0b67af5492adb31940ee418a5a655c28e48165da5afab8c7fa6fd72a142f8740", size = 250823, upload-time = "2026-03-17T10:32:25.516Z" }, - { url = "https://files.pythonhosted.org/packages/8a/fb/616d95d3adb88b9803b275580bdeee8bd1b69a886d057652521f83d7322f/coverage-7.13.5-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:c9136ff29c3a91e25b1d1552b5308e53a1e0653a23e53b6366d7c2dcbbaf8a16", size = 255099, upload-time = "2026-03-17T10:32:27.944Z" }, - { url = "https://files.pythonhosted.org/packages/1c/93/25e6917c90ec1c9a56b0b26f6cad6408e5f13bb6b35d484a0d75c9cf000d/coverage-7.13.5-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:cff784eef7f0b8f6cb28804fbddcfa99f89efe4cc35fb5627e3ac58f91ed3ac0", size = 250638, upload-time = "2026-03-17T10:32:29.914Z" }, - { url = "https://files.pythonhosted.org/packages/fc/7b/dc1776b0464145a929deed214aef9fb1493f159b59ff3c7eeeedf91eddd0/coverage-7.13.5-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:68a4953be99b17ac3c23b6efbc8a38330d99680c9458927491d18700ef23ded0", size = 252295, upload-time = "2026-03-17T10:32:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/ea/fb/99cbbc56a26e07762a2740713f3c8f9f3f3106e3a3dd8cc4474954bccd34/coverage-7.13.5-cp314-cp314-win32.whl", hash = "sha256:35a31f2b1578185fbe6aa2e74cea1b1d0bbf4c552774247d9160d29b80ed56cc", size = 222360, upload-time = "2026-03-17T10:32:34.233Z" }, - { url = "https://files.pythonhosted.org/packages/8d/b7/4758d4f73fb536347cc5e4ad63662f9d60ba9118cb6785e9616b2ce5d7fa/coverage-7.13.5-cp314-cp314-win_amd64.whl", hash = "sha256:2aa055ae1857258f9e0045be26a6d62bdb47a72448b62d7b55f4820f361a2633", size = 223174, upload-time = "2026-03-17T10:32:36.369Z" }, - { url = "https://files.pythonhosted.org/packages/2c/f2/24d84e1dfe70f8ac9fdf30d338239860d0d1d5da0bda528959d0ebc9da28/coverage-7.13.5-cp314-cp314-win_arm64.whl", hash = "sha256:1b11eef33edeae9d142f9b4358edb76273b3bfd30bc3df9a4f95d0e49caf94e8", size = 221739, upload-time = "2026-03-17T10:32:38.736Z" }, - { url = "https://files.pythonhosted.org/packages/60/5b/4a168591057b3668c2428bff25dd3ebc21b629d666d90bcdfa0217940e84/coverage-7.13.5-cp314-cp314t-macosx_10_15_x86_64.whl", hash = "sha256:10a0c37f0b646eaff7cce1874c31d1f1ccb297688d4c747291f4f4c70741cc8b", size = 220351, upload-time = "2026-03-17T10:32:41.196Z" }, - { url = "https://files.pythonhosted.org/packages/f5/21/1fd5c4dbfe4a58b6b99649125635df46decdfd4a784c3cd6d410d303e370/coverage-7.13.5-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:b5db73ba3c41c7008037fa731ad5459fc3944cb7452fc0aa9f822ad3533c583c", size = 220612, upload-time = "2026-03-17T10:32:43.204Z" }, - { url = "https://files.pythonhosted.org/packages/d6/fe/2a924b3055a5e7e4512655a9d4609781b0d62334fa0140c3e742926834e2/coverage-7.13.5-cp314-cp314t-manylinux1_i686.manylinux_2_28_i686.manylinux_2_5_i686.whl", hash = "sha256:750db93a81e3e5a9831b534be7b1229df848b2e125a604fe6651e48aa070e5f9", size = 261985, upload-time = "2026-03-17T10:32:45.514Z" }, - { url = "https://files.pythonhosted.org/packages/d7/0d/c8928f2bd518c45990fe1a2ab8db42e914ef9b726c975facc4282578c3eb/coverage-7.13.5-cp314-cp314t-manylinux1_x86_64.manylinux_2_28_x86_64.manylinux_2_5_x86_64.whl", hash = "sha256:9ddb4f4a5479f2539644be484da179b653273bca1a323947d48ab107b3ed1f29", size = 264107, upload-time = "2026-03-17T10:32:47.971Z" }, - { url = "https://files.pythonhosted.org/packages/ef/ae/4ae35bbd9a0af9d820362751f0766582833c211224b38665c0f8de3d487f/coverage-7.13.5-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d8a7a2049c14f413163e2bdabd37e41179b1d1ccb10ffc6ccc4b7a718429c607", size = 266513, upload-time = "2026-03-17T10:32:50.1Z" }, - { url = "https://files.pythonhosted.org/packages/9c/20/d326174c55af36f74eac6ae781612d9492f060ce8244b570bb9d50d9d609/coverage-7.13.5-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:e1c85e0b6c05c592ea6d8768a66a254bfb3874b53774b12d4c89c481eb78cb90", size = 267650, upload-time = "2026-03-17T10:32:52.391Z" }, - { url = "https://files.pythonhosted.org/packages/7a/5e/31484d62cbd0eabd3412e30d74386ece4a0837d4f6c3040a653878bfc019/coverage-7.13.5-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:777c4d1eff1b67876139d24288aaf1817f6c03d6bae9c5cc8d27b83bcfe38fe3", size = 261089, upload-time = "2026-03-17T10:32:54.544Z" }, - { url = "https://files.pythonhosted.org/packages/e9/d8/49a72d6de146eebb0b7e48cc0f4bc2c0dd858e3d4790ab2b39a2872b62bd/coverage-7.13.5-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:6697e29b93707167687543480a40f0db8f356e86d9f67ddf2e37e2dfd91a9dab", size = 263982, upload-time = "2026-03-17T10:32:56.803Z" }, - { url = "https://files.pythonhosted.org/packages/06/3b/0351f1bd566e6e4dd39e978efe7958bde1d32f879e85589de147654f57bb/coverage-7.13.5-cp314-cp314t-musllinux_1_2_i686.whl", hash = "sha256:8fdf453a942c3e4d99bd80088141c4c6960bb232c409d9c3558e2dbaa3998562", size = 261579, upload-time = "2026-03-17T10:32:59.466Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ce/796a2a2f4017f554d7810f5c573449b35b1e46788424a548d4d19201b222/coverage-7.13.5-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:32ca0c0114c9834a43f045a87dcebd69d108d8ffb666957ea65aa132f50332e2", size = 265316, upload-time = "2026-03-17T10:33:01.847Z" }, - { url = "https://files.pythonhosted.org/packages/3d/16/d5ae91455541d1a78bc90abf495be600588aff8f6db5c8b0dae739fa39c9/coverage-7.13.5-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:8769751c10f339021e2638cd354e13adeac54004d1941119b2c96fe5276d45ea", size = 260427, upload-time = "2026-03-17T10:33:03.945Z" }, - { url = "https://files.pythonhosted.org/packages/48/11/07f413dba62db21fb3fad5d0de013a50e073cc4e2dc4306e770360f6dfc8/coverage-7.13.5-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:cec2d83125531bd153175354055cdb7a09987af08a9430bd173c937c6d0fba2a", size = 262745, upload-time = "2026-03-17T10:33:06.285Z" }, - { url = "https://files.pythonhosted.org/packages/91/15/d792371332eb4663115becf4bad47e047d16234b1aff687b1b18c58d60ae/coverage-7.13.5-cp314-cp314t-win32.whl", hash = "sha256:0cd9ed7a8b181775459296e402ca4fb27db1279740a24e93b3b41942ebe4b215", size = 223146, upload-time = "2026-03-17T10:33:08.756Z" }, - { url = "https://files.pythonhosted.org/packages/db/51/37221f59a111dca5e85be7dbf09696323b5b9f13ff65e0641d535ed06ea8/coverage-7.13.5-cp314-cp314t-win_amd64.whl", hash = "sha256:301e3b7dfefecaca37c9f1aa6f0049b7d4ab8dd933742b607765d757aca77d43", size = 224254, upload-time = "2026-03-17T10:33:11.174Z" }, - { url = "https://files.pythonhosted.org/packages/54/83/6acacc889de8987441aa7d5adfbdbf33d288dad28704a67e574f1df9bcbb/coverage-7.13.5-cp314-cp314t-win_arm64.whl", hash = "sha256:9dacc2ad679b292709e0f5fc1ac74a6d4d5562e424058962c7bb0c658ad25e45", size = 222276, upload-time = "2026-03-17T10:33:13.466Z" }, - { url = "https://files.pythonhosted.org/packages/9e/ee/a4cf96b8ce1e566ed238f0659ac2d3f007ed1d14b181bcb684e19561a69a/coverage-7.13.5-py3-none-any.whl", hash = "sha256:34b02417cf070e173989b3db962f7ed56d2f644307b2cf9d5a0f258e13084a61", size = 211346, upload-time = "2026-03-17T10:33:15.691Z" }, +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" }, ] [package.optional-dependencies] @@ -820,32 +815,32 @@ wheels = [ [[package]] name = "cuda-pathfinder" -version = "1.5.3" +version = "1.5.4" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d3/d6/ac63065d33dd700fee7ebd7d287332401b54e31b9346e142f871e1f0b116/cuda_pathfinder-1.5.3-py3-none-any.whl", hash = "sha256:dff021123aedbb4117cc7ec81717bbfe198fb4e8b5f1ee57e0e084fec5c8577d", size = 49991, upload-time = "2026-04-14T20:09:27.037Z" }, + { 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" }, ] [[package]] name = "cudensitymat-cu13" -version = "0.5.1" +version = "0.5.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cutensor-cu13", marker = "python_full_version >= '3.11'" }, { name = "cutensornet-cu13", marker = "python_full_version >= '3.11'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/c6/124e2bb4123cb5653ccb4b5bd8e86725cacad02f936fe279b525cccfb9a7/cudensitymat_cu13-0.5.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:e94d861c1b360ac7af65824a6220b53f0f76fa05b04dd51872bfd862a6d841ef", size = 15808616, upload-time = "2026-04-13T18:35:08.647Z" }, - { url = "https://files.pythonhosted.org/packages/34/49/6bf9f7a7cebbe9cb8f21da5ccf6d309b2cfb2b6b4aad817344308ef431ba/cudensitymat_cu13-0.5.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:3285f7de8219171e539b60da49a75f8a4fe4934a5c891379ea39589f00b819ac", size = 15836044, upload-time = "2026-04-13T18:38:47.036Z" }, + { url = "https://files.pythonhosted.org/packages/80/35/d315cddc6b5acf7f275b2d306b4f9cd197dc35deaed4719d9fc86850ec9f/cudensitymat_cu13-0.5.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:ec3c22e98e472622eac7d3701572c38a859e992f5719ef38d818e0c9da788477", size = 15808498, upload-time = "2026-04-30T23:57:57.021Z" }, + { url = "https://files.pythonhosted.org/packages/fd/67/8ccb65ea1233301bbfdff67f2120df370bc16b716b8b3d0ec4a61a1ec50e/cudensitymat_cu13-0.5.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:74b28ad826316707bf53ff8f533de9bb9802d6a786e24221d80cda688dce03a8", size = 15836100, upload-time = "2026-04-30T23:52:43.267Z" }, ] [[package]] name = "cupauliprop-cu13" -version = "0.3.1" +version = "0.3.2" source = { registry = "https://pypi.org/simple" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d5/1b/8739da5124bc9e192d3498339f7f2849289eaa773600ce176f8f764237c6/cupauliprop_cu13-0.3.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:31ec5ebdc5ac8a6b8f391305f9dea2f9ab837c928e587835d1f1582d04d16aae", size = 52749659, upload-time = "2026-04-13T18:34:12.551Z" }, - { url = "https://files.pythonhosted.org/packages/a3/64/b63a114c30ab01f304675ac6d377bace289241481062b1ee091d8940e200/cupauliprop_cu13-0.3.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:8163289ecefd0aac2f56201f0ca3fc0a5828f45c1c114e969de9fe563e05d04e", size = 53067797, upload-time = "2026-04-13T18:38:17.181Z" }, + { url = "https://files.pythonhosted.org/packages/37/7c/c44e93a2e7e66dfff3b46714ae0390c3d646a06a02a2772ffedbb14832f6/cupauliprop_cu13-0.3.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:df2b26ca9137cc4a2ad3b3b5ce18a759cbf019fcf9d27341bb7398f63e364146", size = 52781066, upload-time = "2026-04-30T23:57:04.428Z" }, + { url = "https://files.pythonhosted.org/packages/90/b8/d05707092c441c413655cece349e9de387d664c0955c77550fa98467934f/cupauliprop_cu13-0.3.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:05722c6c72e27b7aedaaea6bda1d21f7c9d84b1eaad172ef3a7e738702cfad5a", size = 53097635, upload-time = "2026-04-30T23:52:17.447Z" }, ] [[package]] @@ -876,7 +871,7 @@ wheels = [ [[package]] name = "cuquantum-python-cu13" -version = "26.3.1" +version = "26.3.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-bindings", marker = "python_full_version >= '3.11'" }, @@ -890,12 +885,12 @@ dependencies = [ { name = "nvmath-python", marker = "python_full_version >= '3.11'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/b5/10/fd057de76c62c51cc1dd4611bdaffc36bfcd6eb1d1505c95fb7d6172c455/cuquantum_python_cu13-26.3.1-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:a2374bda57b6f92afd2d7165527e113f58ebbfd92865789e28d4653d7f5bd6b3", size = 8779167, upload-time = "2026-04-13T18:40:53.147Z" }, - { url = "https://files.pythonhosted.org/packages/95/e8/20595f9f6ae9a2aece49fd96f30053c6c98c5b9202f15d5f23c1381125b0/cuquantum_python_cu13-26.3.1-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:dc43af3ff93cbc936d211b9e0bfe2627c3005ed02dd0eb016c3b2e6740c36a83", size = 8706739, upload-time = "2026-04-13T18:43:40.026Z" }, - { url = "https://files.pythonhosted.org/packages/39/20/f5e9d15c125f2c73d7faab5d052ede6e7adb4cca59f7e74be75e3f52331f/cuquantum_python_cu13-26.3.1-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:7c9668c8edaec6cce8a2dccc035a701a53d5a0394bb22dbc33ee85df01de03e6", size = 8810795, upload-time = "2026-04-13T18:40:14.751Z" }, - { url = "https://files.pythonhosted.org/packages/08/8a/1a2a479d9090d76b373d4a02b10a5ebf005c6d4701bfc97bfa0efa7fd449/cuquantum_python_cu13-26.3.1-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:1be2eab8ac9ac66eea07d47308c541fad83f9a3b01f936d99aeae82036556093", size = 8765906, upload-time = "2026-04-13T18:43:20.995Z" }, - { url = "https://files.pythonhosted.org/packages/83/c3/b4792973ecd7929eda17d882ae1bb1e1b7e5456e13f4fb9d8af5bf08c976/cuquantum_python_cu13-26.3.1-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:3657c07291d5904a2afe9e6d41acd5ff10ba66d59ecd64e2bc30d693b6067ea0", size = 8798205, upload-time = "2026-04-13T18:39:35.537Z" }, - { url = "https://files.pythonhosted.org/packages/53/0d/9af8191ad5ab46e8220ccc3bf617a454eea3bcb58bef24a1399f04e9e7ec/cuquantum_python_cu13-26.3.1-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:c58bb0b7cd9d28f25e4987b062c3c31bd270a88de436997ed3c38869c26c78b5", size = 8724338, upload-time = "2026-04-13T18:42:59.543Z" }, + { url = "https://files.pythonhosted.org/packages/ef/bd/db7e127084c5d8b60970114a138e65bf29c822189103e3bf655598ef2838/cuquantum_python_cu13-26.3.2-cp311-cp311-manylinux2014_aarch64.whl", hash = "sha256:d6eda6360c0e06b4211866c5dacbe2fc70d822b46276dff2b1c428b908161db2", size = 8779331, upload-time = "2026-05-01T00:03:21.764Z" }, + { url = "https://files.pythonhosted.org/packages/9d/af/b1b65ce1521fe8bd4bf8e71227756ca837a52f0a57db63862ecb5c46249c/cuquantum_python_cu13-26.3.2-cp311-cp311-manylinux2014_x86_64.whl", hash = "sha256:ffeb1bdc31e8283665ffc566723483295186d552df2c4a25b40a73f6d54d5c3b", size = 8706900, upload-time = "2026-04-30T23:59:09.972Z" }, + { url = "https://files.pythonhosted.org/packages/84/23/555b8a60c7a2388216f04feed2cdce6b5678cf575c374f74a21df6816ad4/cuquantum_python_cu13-26.3.2-cp312-cp312-manylinux2014_aarch64.whl", hash = "sha256:b5e726bec9a3fe61e22fabda33af583a01d31b82c44f0066d69cb100488d5716", size = 8810961, upload-time = "2026-05-01T00:02:37.591Z" }, + { url = "https://files.pythonhosted.org/packages/02/0c/e24ead4771d061e2f0366be96e9e300f9cb992dce7c3e23f6d3755c02606/cuquantum_python_cu13-26.3.2-cp312-cp312-manylinux2014_x86_64.whl", hash = "sha256:c7017f7a53034559295007a8e7cf8f1fbf74a74ebb3a1318e074154de2c310d9", size = 8766068, upload-time = "2026-04-30T23:58:48.49Z" }, + { url = "https://files.pythonhosted.org/packages/e3/0a/47fd861a7673ca6d95029008d280da5bfdbdab31fcedac1f646a8aea24ef/cuquantum_python_cu13-26.3.2-cp313-cp313-manylinux2014_aarch64.whl", hash = "sha256:c17c8b33387ca36542fa0eca74c794975d43eb6cae90ffe52484dc8ba2d2e927", size = 8798288, upload-time = "2026-05-01T00:01:53.533Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1a/7469ab32a47bcd097ed21d6aca1137bb0fe7bca7020cbf50d99b4daf082d/cuquantum_python_cu13-26.3.2-cp313-cp313-manylinux2014_x86_64.whl", hash = "sha256:b2840f6edf73eae4600c380d9f617cf4c8ff32ec12ce398f4e991c4a811a0d92", size = 8724906, upload-time = "2026-04-30T23:58:29.653Z" }, ] [[package]] @@ -928,14 +923,14 @@ wheels = [ [[package]] name = "cutensornet-cu13" -version = "2.12.1" +version = "2.12.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cutensor-cu13", marker = "python_full_version >= '3.11'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/8f/6a/799139d21eeab7a92bd3b5c6f16ea8c2242e5438bb8491a1d1aa30926d61/cutensornet_cu13-2.12.1-py3-none-manylinux2014_aarch64.whl", hash = "sha256:53011d63cbebff0031be5f841253dfc12b392fa53067b83802813f5ae7c359cb", size = 2911235, upload-time = "2026-04-13T18:23:51.218Z" }, - { url = "https://files.pythonhosted.org/packages/0c/af/b4243523b3a19d5420ed2614105832b0c76d1a5b9383e6351f0d442ab955/cutensornet_cu13-2.12.1-py3-none-manylinux2014_x86_64.whl", hash = "sha256:98b3bed3132379f03ff1bc15e43368f5f448bc26e2dd2cdfb77bb71288a17818", size = 2998683, upload-time = "2026-04-13T18:11:55.296Z" }, + { url = "https://files.pythonhosted.org/packages/c8/a9/59db9aa28e7b54ff7b7f134dd5708c8b5a4ed3b7f5ecb54247092b30019b/cutensornet_cu13-2.12.2-py3-none-manylinux2014_aarch64.whl", hash = "sha256:f0e956fcbbb96eba95df426a6747c2078ee5538cdc18501e77efc2e031b5e0be", size = 2911602, upload-time = "2026-04-30T23:32:22.669Z" }, + { url = "https://files.pythonhosted.org/packages/5e/63/3466befda731425027faaa16f29d3ff44c61a8e7b94d82385d0f90e724c1/cutensornet_cu13-2.12.2-py3-none-manylinux2014_x86_64.whl", hash = "sha256:70a006473dbef5094ec514f4b9dbc3067251fc898a226a04907e2b33d12b4c08", size = 2998684, upload-time = "2026-04-30T23:30:56.267Z" }, ] [[package]] @@ -1035,11 +1030,11 @@ wheels = [ [[package]] name = "filelock" -version = "3.28.0" +version = "3.29.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/d6/17/6e8890271880903e3538660a21d63a6c1fea969ac71d0d6b608b78727fa9/filelock-3.28.0.tar.gz", hash = "sha256:4ed1010aae813c4ee8d9c660e4792475ee60c4a0ba76073ceaf862bd317e3ca6", size = 56474, upload-time = "2026-04-14T22:54:33.625Z" } +sdist = { url = "https://files.pythonhosted.org/packages/b5/fe/997687a931ab51049acce6fa1f23e8f01216374ea81374ddee763c493db5/filelock-3.29.0.tar.gz", hash = "sha256:69974355e960702e789734cb4871f884ea6fe50bd8404051a3530bc07809cf90", size = 57571, upload-time = "2026-04-19T15:39:10.068Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/21/2f728888c45033d34a417bfcd248ea2564c9e08ab1bfd301377cf05d5586/filelock-3.28.0-py3-none-any.whl", hash = "sha256:de9af6712788e7171df1b28b15eba2446c69721433fa427a9bee07b17820a9db", size = 39189, upload-time = "2026-04-14T22:54:32.037Z" }, + { url = "https://files.pythonhosted.org/packages/81/47/dd9a212ef6e343a6857485ffe25bba537304f1913bdbed446a23f7f592e1/filelock-3.29.0-py3-none-any.whl", hash = "sha256:96f5f6344709aa1572bbf631c640e4ebeeb519e08da902c39a001882f30ac258", size = 39812, upload-time = "2026-04-19T15:39:08.752Z" }, ] [[package]] @@ -1268,20 +1263,20 @@ wheels = [ [[package]] name = "identify" -version = "2.6.18" +version = "2.6.19" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/46/c4/7fb4db12296cdb11893d61c92048fe617ee853f8523b9b296ac03b43757e/identify-2.6.18.tar.gz", hash = "sha256:873ac56a5e3fd63e7438a7ecbc4d91aca692eb3fefa4534db2b7913f3fc352fd", size = 99580, upload-time = "2026-03-15T18:39:50.319Z" } +sdist = { url = "https://files.pythonhosted.org/packages/52/63/51723b5f116cc04b061cb6f5a561790abf249d25931d515cd375e063e0f4/identify-2.6.19.tar.gz", hash = "sha256:6be5020c38fcb07da56c53733538a3081ea5aa70d36a156f83044bfbf9173842", size = 99567, upload-time = "2026-04-17T18:39:50.265Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/46/33/92ef41c6fad0233e41d3d84ba8e8ad18d1780f1e5d99b3c683e6d7f98b63/identify-2.6.18-py2.py3-none-any.whl", hash = "sha256:8db9d3c8ea9079db92cafb0ebf97abdc09d52e97f4dcf773a2e694048b7cd737", size = 99394, upload-time = "2026-03-15T18:39:48.915Z" }, + { url = "https://files.pythonhosted.org/packages/94/84/d9273cd09688070a6523c4aee4663a8538721b2b755c4962aafae0011e72/identify-2.6.19-py2.py3-none-any.whl", hash = "sha256:20e6a87f786f768c092a721ad107fc9df0eb89347be9396cadf3f4abbd1fb78a", size = 99397, upload-time = "2026-04-17T18:39:49.221Z" }, ] [[package]] name = "idna" -version = "3.11" +version = "3.14" 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/05/b1/efac073e0c297ecf2fb33c346989a529d4e19164f1759102dee5953ee17e/idna-3.14.tar.gz", hash = "sha256:466d810d7a2cc1022bea9b037c39728d51ae7dad40d480fc9b7d7ecf98ba8ee3", size = 198272, upload-time = "2026-05-10T20:32:15.935Z" } 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/6c/3c/3f62dee257eb3d6b2c1ef2a09d36d9793c7111156a73b5654d2c2305e5ce/idna-3.14-py3-none-any.whl", hash = "sha256:e677eaf072e290f7b725f9acf0b3a2bd55f9fd6f7c70abe5f0e34823d0accf69", size = 72184, upload-time = "2026-05-10T20:32:14.295Z" }, ] [[package]] @@ -1302,8 +1297,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.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "jupyter-client" }, { name = "jupyter-core" }, { name = "matplotlib-inline" }, @@ -1347,55 +1341,31 @@ wheels = [ [[package]] name = "ipython" -version = "9.10.1" -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')", -] -dependencies = [ - { name = "colorama", marker = "python_full_version == '3.11.*' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version == '3.11.*'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version == '3.11.*'" }, - { name = "jedi", marker = "python_full_version == '3.11.*'" }, - { 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 = "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/c5/25/daae0e764047b0a2480c7bbb25d48f4f509b5818636562eeac145d06dfee/ipython-9.10.1.tar.gz", hash = "sha256:e170e9b2a44312484415bdb750492699bf329233b03f2557a9692cce6466ada4", size = 4426663, upload-time = "2026-03-27T09:53:26.244Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/01/09/ba70f8d662d5671687da55ad2cc0064cf795b15e1eea70907532202e7c97/ipython-9.10.1-py3-none-any.whl", hash = "sha256:82d18ae9fb9164ded080c71ef92a182ee35ee7db2395f67616034bebb020a232", size = 622827, upload-time = "2026-03-27T09:53:24.566Z" }, -] - -[[package]] -name = "ipython" -version = "9.12.0" +version = "9.13.0" 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.12' and 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.12' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.12' and 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')", ] dependencies = [ - { name = "colorama", marker = "python_full_version >= '3.12' and sys_platform == 'win32'" }, - { name = "decorator", marker = "python_full_version >= '3.12'" }, - { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.12'" }, - { name = "jedi", marker = "python_full_version >= '3.12'" }, - { name = "matplotlib-inline", marker = "python_full_version >= '3.12'" }, - { name = "pexpect", marker = "python_full_version >= '3.12' and sys_platform != 'emscripten' and sys_platform != 'win32'" }, - { name = "prompt-toolkit", marker = "python_full_version >= '3.12'" }, - { name = "pygments", marker = "python_full_version >= '3.12'" }, - { name = "stack-data", marker = "python_full_version >= '3.12'" }, - { name = "traitlets", marker = "python_full_version >= '3.12'" }, + { name = "colorama", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, + { name = "decorator", marker = "python_full_version >= '3.11'" }, + { name = "ipython-pygments-lexers", marker = "python_full_version >= '3.11'" }, + { name = "jedi", marker = "python_full_version >= '3.11'" }, + { 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 = "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/3a/73/7114f80a8f9cabdb13c27732dce24af945b2923dcab80723602f7c8bc2d8/ipython-9.12.0.tar.gz", hash = "sha256:01daa83f504b693ba523b5a407246cabde4eb4513285a3c6acaff11a66735ee4", size = 4428879, upload-time = "2026-03-27T09:42:45.312Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/59/22/906c8108974c673ebef6356c506cebb6870d48cedea3c41e949e2dd556bb/ipython-9.12.0-py3-none-any.whl", hash = "sha256:0f2701e8ee86e117e37f50563205d36feaa259d2e08d4a6bc6b6d74b18ce128d", size = 625661, upload-time = "2026-03-27T09:42:42.831Z" }, + { 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" }, ] [[package]] @@ -1417,8 +1387,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.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "jupyterlab-widgets" }, { name = "traitlets" }, { name = "widgetsnbextension" }, @@ -1442,14 +1411,14 @@ wheels = [ [[package]] name = "jedi" -version = "0.19.2" +version = "0.20.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "parso" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/72/3a/79a912fbd4d8dd6fbb02bf69afd3bb72cf0c729bb3063c6f4498603db17a/jedi-0.19.2.tar.gz", hash = "sha256:4770dc3de41bde3966b02eb84fbcf557fb33cce26ad23da12c742fb50ecb11f0", size = 1231287, upload-time = "2024-11-11T01:41:42.873Z" } +sdist = { url = "https://files.pythonhosted.org/packages/46/b7/a3635f6a2d7cf5b5dd98064fc1d5fbbafcb25477bcea204a3a92145d158b/jedi-0.20.0.tar.gz", hash = "sha256:c3f4ccbd276696f4b19c54618d4fb18f9fc24b0aef02acf704b23f487daa1011", size = 3119416, upload-time = "2026-05-01T23:38:47.814Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/c0/5a/9cac0c82afec3d09ccd97c8b6502d48f165f9124db81b4bcb90b4af974ee/jedi-0.19.2-py2.py3-none-any.whl", hash = "sha256:a8ef22bde8490f57fe5c7681a3c83cb58874daf72b4784de3cce5b6ef6edb5b9", size = 1572278, upload-time = "2024-11-11T01:41:40.175Z" }, + { url = "https://files.pythonhosted.org/packages/9a/93/242e2eab5fe682ffcb8b0084bde703a41d51e17ee0f3a31ff0d9d813620a/jedi-0.20.0-py2.py3-none-any.whl", hash = "sha256:7bdd9c2634f56713299976f4cbd59cb3fa92165cc5e05ea811fb253480728b67", size = 4884812, upload-time = "2026-05-01T23:38:43.919Z" }, ] [[package]] @@ -1562,8 +1531,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.10.1", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version == '3.11.*'" }, - { name = "ipython", version = "9.12.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.12'" }, + { name = "ipython", version = "9.13.0", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, { name = "jupyter-client" }, { name = "jupyter-core" }, { name = "prompt-toolkit" }, @@ -1591,7 +1559,7 @@ wheels = [ [[package]] name = "jupyter-events" -version = "0.12.0" +version = "0.12.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "jsonschema", extra = ["format-nongpl"] }, @@ -1603,9 +1571,9 @@ dependencies = [ { name = "rfc3986-validator" }, { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/9d/c3/306d090461e4cf3cd91eceaff84bede12a8e52cd821c2d20c9a4fd728385/jupyter_events-0.12.0.tar.gz", hash = "sha256:fc3fce98865f6784c9cd0a56a20644fc6098f21c8c33834a8d9fe383c17e554b", size = 62196, upload-time = "2025-02-03T17:23:41.485Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/f8/475c4241b2b75af0deaae453ed003c6c851766dbc44d332d8baf245dc931/jupyter_events-0.12.1.tar.gz", hash = "sha256:faff25f77218335752f35f23c5fe6e4a392a7bd99a5939ccb9b8fbf594636cf3", size = 62854, upload-time = "2026-04-20T23:17:50.66Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/e2/48/577993f1f99c552f18a0428731a755e06171f9902fa118c379eb7c04ea22/jupyter_events-0.12.0-py3-none-any.whl", hash = "sha256:6464b2fa5ad10451c3d35fabc75eab39556ae1e2853ad0c0cc31b656731a97fb", size = 19430, upload-time = "2025-02-03T17:23:38.643Z" }, + { url = "https://files.pythonhosted.org/packages/eb/6c/6fcde0c8f616ed360ffd3587f7db9e225a7e62b583a04494d2f069cf64ea/jupyter_events-0.12.1-py3-none-any.whl", hash = "sha256:c366585253f537a627da52fa7ca7410c5b5301fe893f511e7b077c2d93ec8bcf", size = 19512, upload-time = "2026-04-20T23:17:48.927Z" }, ] [[package]] @@ -1622,7 +1590,7 @@ wheels = [ [[package]] name = "jupyter-server" -version = "2.18.0" +version = "2.18.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "anyio" }, @@ -1645,9 +1613,9 @@ dependencies = [ { name = "traitlets" }, { name = "websocket-client" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/f1/ec/9302cec1ccacdd33c1b1312ac31681c8975cae56c626d783ab49edf9c681/jupyter_server-2.18.0.tar.gz", hash = "sha256:568b27bce4320a53c3eebf1bdcbee9acf48a8ab7f66ec83d900ca9909d4fb770", size = 751152, upload-time = "2026-05-04T13:39:29.685Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/cf/f9/050312d92072ddb9ce14c11171804c07435790c98d4350935a780d9e10c2/jupyter_server-2.18.0-py3-none-any.whl", hash = "sha256:69a5397a039d689da81a45955f9b23e95ee167f6d8a8d64372fb616f2aac650a", size = 391687, upload-time = "2026-05-04T13:39:27.549Z" }, + { 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" }, ] [[package]] @@ -1925,8 +1893,7 @@ version = "0.45.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.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", - "python_full_version == '3.11.*' 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.11' and platform_machine == 'x86_64' and sys_platform == 'darwin'", ] sdist = { url = "https://files.pythonhosted.org/packages/99/8d/5baf1cef7f9c084fb35a8afbde88074f0d6a727bc63ef764fe0e7543ba40/llvmlite-0.45.1.tar.gz", hash = "sha256:09430bb9d0bb58fc45a45a57c7eae912850bedc095cd0810a57de109c69e1c32", size = 185600, upload-time = "2025-10-01T17:59:52.046Z" } @@ -1943,8 +1910,7 @@ version = "0.47.0" source = { registry = "https://pypi.org/simple" } 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.12' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin')", - "(python_full_version == '3.11.*' and platform_machine != 'x86_64') or (python_full_version == '3.11.*' 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')", "(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/01/88/a8952b6d5c21e74cbf158515b779666f692846502623e9e3c39d8e8ba25f/llvmlite-0.47.0.tar.gz", hash = "sha256:62031ce968ec74e95092184d4b0e857e444f8fdff0b8f9213707699570c33ccc", size = 193614, upload-time = "2026-03-31T18:29:53.497Z" } @@ -2003,14 +1969,14 @@ ansi = [ [[package]] name = "markdown-it-py" -version = "4.0.0" +version = "4.2.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" } +sdist = { url = "https://files.pythonhosted.org/packages/06/ff/7841249c247aa650a76b9ee4bbaeae59370dc8bfd2f6c01f3630c35eb134/markdown_it_py-4.2.0.tar.gz", hash = "sha256:04a21681d6fbb623de53f6f364d352309d4094dd4194040a10fd51833e418d49", size = 82454, upload-time = "2026-05-07T12:08:28.36Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/b3/81/4da04ced5a082363ecfa159c010d200ecbd959ae410c10c0264a38cac0f5/markdown_it_py-4.2.0-py3-none-any.whl", hash = "sha256:9f7ebbcd14fe59494226453aed97c1070d83f8d24b6fc3a3bcf9a38092641c4a", size = 91687, upload-time = "2026-05-07T12:08:27.182Z" }, ] [[package]] @@ -2100,7 +2066,7 @@ wheels = [ [[package]] name = "matplotlib" -version = "3.10.8" +version = "3.10.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "contourpy", version = "1.3.2", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version < '3.11'" }, @@ -2115,98 +2081,98 @@ dependencies = [ { name = "pyparsing" }, { name = "python-dateutil" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/8a/76/d3c6e3a13fe484ebe7718d14e269c9569c4eb0020a968a327acb3b9a8fe6/matplotlib-3.10.8.tar.gz", hash = "sha256:2299372c19d56bcd35cf05a2738308758d32b9eaed2371898d8f5bd33f084aa3", size = 34806269, upload-time = "2025-12-10T22:56:51.155Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/58/be/a30bd917018ad220c400169fba298f2bb7003c8ccbc0c3e24ae2aacad1e8/matplotlib-3.10.8-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:00270d217d6b20d14b584c521f810d60c5c78406dc289859776550df837dcda7", size = 8239828, upload-time = "2025-12-10T22:55:02.313Z" }, - { url = "https://files.pythonhosted.org/packages/58/27/ca01e043c4841078e82cf6e80a6993dfecd315c3d79f5f3153afbb8e1ec6/matplotlib-3.10.8-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:37b3c1cc42aa184b3f738cfa18c1c1d72fd496d85467a6cf7b807936d39aa656", size = 8128050, upload-time = "2025-12-10T22:55:04.997Z" }, - { url = "https://files.pythonhosted.org/packages/cb/aa/7ab67f2b729ae6a91bcf9dcac0affb95fb8c56f7fd2b2af894ae0b0cf6fa/matplotlib-3.10.8-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ee40c27c795bda6a5292e9cff9890189d32f7e3a0bf04e0e3c9430c4a00c37df", size = 8700452, upload-time = "2025-12-10T22:55:07.47Z" }, - { url = "https://files.pythonhosted.org/packages/73/ae/2d5817b0acee3c49b7e7ccfbf5b273f284957cc8e270adf36375db353190/matplotlib-3.10.8-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:a48f2b74020919552ea25d222d5cc6af9ca3f4eb43a93e14d068457f545c2a17", size = 9534928, upload-time = "2025-12-10T22:55:10.566Z" }, - { url = "https://files.pythonhosted.org/packages/c9/5b/8e66653e9f7c39cb2e5cab25fce4810daffa2bff02cbf5f3077cea9e942c/matplotlib-3.10.8-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:f254d118d14a7f99d616271d6c3c27922c092dac11112670b157798b89bf4933", size = 9586377, upload-time = "2025-12-10T22:55:12.362Z" }, - { url = "https://files.pythonhosted.org/packages/e2/e2/fd0bbadf837f81edb0d208ba8f8cb552874c3b16e27cb91a31977d90875d/matplotlib-3.10.8-cp310-cp310-win_amd64.whl", hash = "sha256:f9b587c9c7274c1613a30afabf65a272114cd6cdbe67b3406f818c79d7ab2e2a", size = 8128127, upload-time = "2025-12-10T22:55:14.436Z" }, - { url = "https://files.pythonhosted.org/packages/f8/86/de7e3a1cdcfc941483af70609edc06b83e7c8a0e0dc9ac325200a3f4d220/matplotlib-3.10.8-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:6be43b667360fef5c754dda5d25a32e6307a03c204f3c0fc5468b78fa87b4160", size = 8251215, upload-time = "2025-12-10T22:55:16.175Z" }, - { url = "https://files.pythonhosted.org/packages/fd/14/baad3222f424b19ce6ad243c71de1ad9ec6b2e4eb1e458a48fdc6d120401/matplotlib-3.10.8-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:a2b336e2d91a3d7006864e0990c83b216fcdca64b5a6484912902cef87313d78", size = 8139625, upload-time = "2025-12-10T22:55:17.712Z" }, - { url = "https://files.pythonhosted.org/packages/8f/a0/7024215e95d456de5883e6732e708d8187d9753a21d32f8ddb3befc0c445/matplotlib-3.10.8-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:efb30e3baaea72ce5928e32bab719ab4770099079d66726a62b11b1ef7273be4", size = 8712614, upload-time = "2025-12-10T22:55:20.8Z" }, - { url = "https://files.pythonhosted.org/packages/5a/f4/b8347351da9a5b3f41e26cf547252d861f685c6867d179a7c9d60ad50189/matplotlib-3.10.8-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:d56a1efd5bfd61486c8bc968fa18734464556f0fb8e51690f4ac25d85cbbbbc2", size = 9540997, upload-time = "2025-12-10T22:55:23.258Z" }, - { url = "https://files.pythonhosted.org/packages/9e/c0/c7b914e297efe0bc36917bf216b2acb91044b91e930e878ae12981e461e5/matplotlib-3.10.8-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:238b7ce5717600615c895050239ec955d91f321c209dd110db988500558e70d6", size = 9596825, upload-time = "2025-12-10T22:55:25.217Z" }, - { url = "https://files.pythonhosted.org/packages/6f/d3/a4bbc01c237ab710a1f22b4da72f4ff6d77eb4c7735ea9811a94ae239067/matplotlib-3.10.8-cp311-cp311-win_amd64.whl", hash = "sha256:18821ace09c763ec93aef5eeff087ee493a24051936d7b9ebcad9662f66501f9", size = 8135090, upload-time = "2025-12-10T22:55:27.162Z" }, - { url = "https://files.pythonhosted.org/packages/89/dd/a0b6588f102beab33ca6f5218b31725216577b2a24172f327eaf6417d5c9/matplotlib-3.10.8-cp311-cp311-win_arm64.whl", hash = "sha256:bab485bcf8b1c7d2060b4fcb6fc368a9e6f4cd754c9c2fea281f4be21df394a2", size = 8012377, upload-time = "2025-12-10T22:55:29.185Z" }, - { url = "https://files.pythonhosted.org/packages/9e/67/f997cdcbb514012eb0d10cd2b4b332667997fb5ebe26b8d41d04962fa0e6/matplotlib-3.10.8-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:64fcc24778ca0404ce0cb7b6b77ae1f4c7231cdd60e6778f999ee05cbd581b9a", size = 8260453, upload-time = "2025-12-10T22:55:30.709Z" }, - { url = "https://files.pythonhosted.org/packages/7e/65/07d5f5c7f7c994f12c768708bd2e17a4f01a2b0f44a1c9eccad872433e2e/matplotlib-3.10.8-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b9a5ca4ac220a0cdd1ba6bcba3608547117d30468fefce49bb26f55c1a3d5c58", size = 8148321, upload-time = "2025-12-10T22:55:33.265Z" }, - { url = "https://files.pythonhosted.org/packages/3e/f3/c5195b1ae57ef85339fd7285dfb603b22c8b4e79114bae5f4f0fcf688677/matplotlib-3.10.8-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3ab4aabc72de4ff77b3ec33a6d78a68227bf1123465887f9905ba79184a1cc04", size = 8716944, upload-time = "2025-12-10T22:55:34.922Z" }, - { url = "https://files.pythonhosted.org/packages/00/f9/7638f5cc82ec8a7aa005de48622eecc3ed7c9854b96ba15bd76b7fd27574/matplotlib-3.10.8-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:24d50994d8c5816ddc35411e50a86ab05f575e2530c02752e02538122613371f", size = 9550099, upload-time = "2025-12-10T22:55:36.789Z" }, - { url = "https://files.pythonhosted.org/packages/57/61/78cd5920d35b29fd2a0fe894de8adf672ff52939d2e9b43cb83cd5ce1bc7/matplotlib-3.10.8-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:99eefd13c0dc3b3c1b4d561c1169e65fe47aab7b8158754d7c084088e2329466", size = 9613040, upload-time = "2025-12-10T22:55:38.715Z" }, - { url = "https://files.pythonhosted.org/packages/30/4e/c10f171b6e2f44d9e3a2b96efa38b1677439d79c99357600a62cc1e9594e/matplotlib-3.10.8-cp312-cp312-win_amd64.whl", hash = "sha256:dd80ecb295460a5d9d260df63c43f4afbdd832d725a531f008dad1664f458adf", size = 8142717, upload-time = "2025-12-10T22:55:41.103Z" }, - { url = "https://files.pythonhosted.org/packages/f1/76/934db220026b5fef85f45d51a738b91dea7d70207581063cd9bd8fafcf74/matplotlib-3.10.8-cp312-cp312-win_arm64.whl", hash = "sha256:3c624e43ed56313651bc18a47f838b60d7b8032ed348911c54906b130b20071b", size = 8012751, upload-time = "2025-12-10T22:55:42.684Z" }, - { url = "https://files.pythonhosted.org/packages/3d/b9/15fd5541ef4f5b9a17eefd379356cf12175fe577424e7b1d80676516031a/matplotlib-3.10.8-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:3f2e409836d7f5ac2f1c013110a4d50b9f7edc26328c108915f9075d7d7a91b6", size = 8261076, upload-time = "2025-12-10T22:55:44.648Z" }, - { url = "https://files.pythonhosted.org/packages/8d/a0/2ba3473c1b66b9c74dc7107c67e9008cb1782edbe896d4c899d39ae9cf78/matplotlib-3.10.8-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:56271f3dac49a88d7fca5060f004d9d22b865f743a12a23b1e937a0be4818ee1", size = 8148794, upload-time = "2025-12-10T22:55:46.252Z" }, - { url = "https://files.pythonhosted.org/packages/75/97/a471f1c3eb1fd6f6c24a31a5858f443891d5127e63a7788678d14e249aea/matplotlib-3.10.8-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:a0a7f52498f72f13d4a25ea70f35f4cb60642b466cbb0a9be951b5bc3f45a486", size = 8718474, upload-time = "2025-12-10T22:55:47.864Z" }, - { url = "https://files.pythonhosted.org/packages/01/be/cd478f4b66f48256f42927d0acbcd63a26a893136456cd079c0cc24fbabf/matplotlib-3.10.8-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:646d95230efb9ca614a7a594d4fcacde0ac61d25e37dd51710b36477594963ce", size = 9549637, upload-time = "2025-12-10T22:55:50.048Z" }, - { url = "https://files.pythonhosted.org/packages/5d/7c/8dc289776eae5109e268c4fb92baf870678dc048a25d4ac903683b86d5bf/matplotlib-3.10.8-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:f89c151aab2e2e23cb3fe0acad1e8b82841fd265379c4cecd0f3fcb34c15e0f6", size = 9613678, upload-time = "2025-12-10T22:55:52.21Z" }, - { url = "https://files.pythonhosted.org/packages/64/40/37612487cc8a437d4dd261b32ca21fe2d79510fe74af74e1f42becb1bdb8/matplotlib-3.10.8-cp313-cp313-win_amd64.whl", hash = "sha256:e8ea3e2d4066083e264e75c829078f9e149fa119d27e19acd503de65e0b13149", size = 8142686, upload-time = "2025-12-10T22:55:54.253Z" }, - { url = "https://files.pythonhosted.org/packages/66/52/8d8a8730e968185514680c2a6625943f70269509c3dcfc0dcf7d75928cb8/matplotlib-3.10.8-cp313-cp313-win_arm64.whl", hash = "sha256:c108a1d6fa78a50646029cb6d49808ff0fc1330fda87fa6f6250c6b5369b6645", size = 8012917, upload-time = "2025-12-10T22:55:56.268Z" }, - { url = "https://files.pythonhosted.org/packages/b5/27/51fe26e1062f298af5ef66343d8ef460e090a27fea73036c76c35821df04/matplotlib-3.10.8-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:ad3d9833a64cf48cc4300f2b406c3d0f4f4724a91c0bd5640678a6ba7c102077", size = 8305679, upload-time = "2025-12-10T22:55:57.856Z" }, - { url = "https://files.pythonhosted.org/packages/2c/1e/4de865bc591ac8e3062e835f42dd7fe7a93168d519557837f0e37513f629/matplotlib-3.10.8-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:eb3823f11823deade26ce3b9f40dcb4a213da7a670013929f31d5f5ed1055b22", size = 8198336, upload-time = "2025-12-10T22:55:59.371Z" }, - { url = "https://files.pythonhosted.org/packages/c6/cb/2f7b6e75fb4dce87ef91f60cac4f6e34f4c145ab036a22318ec837971300/matplotlib-3.10.8-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:d9050fee89a89ed57b4fb2c1bfac9a3d0c57a0d55aed95949eedbc42070fea39", size = 8731653, upload-time = "2025-12-10T22:56:01.032Z" }, - { url = "https://files.pythonhosted.org/packages/46/b3/bd9c57d6ba670a37ab31fb87ec3e8691b947134b201f881665b28cc039ff/matplotlib-3.10.8-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:b44d07310e404ba95f8c25aa5536f154c0a8ec473303535949e52eb71d0a1565", size = 9561356, upload-time = "2025-12-10T22:56:02.95Z" }, - { url = "https://files.pythonhosted.org/packages/c0/3d/8b94a481456dfc9dfe6e39e93b5ab376e50998cddfd23f4ae3b431708f16/matplotlib-3.10.8-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:0a33deb84c15ede243aead39f77e990469fff93ad1521163305095b77b72ce4a", size = 9614000, upload-time = "2025-12-10T22:56:05.411Z" }, - { url = "https://files.pythonhosted.org/packages/bd/cd/bc06149fe5585ba800b189a6a654a75f1f127e8aab02fd2be10df7fa500c/matplotlib-3.10.8-cp313-cp313t-win_amd64.whl", hash = "sha256:3a48a78d2786784cc2413e57397981fb45c79e968d99656706018d6e62e57958", size = 8220043, upload-time = "2025-12-10T22:56:07.551Z" }, - { url = "https://files.pythonhosted.org/packages/e3/de/b22cf255abec916562cc04eef457c13e58a1990048de0c0c3604d082355e/matplotlib-3.10.8-cp313-cp313t-win_arm64.whl", hash = "sha256:15d30132718972c2c074cd14638c7f4592bd98719e2308bccea40e0538bc0cb5", size = 8062075, upload-time = "2025-12-10T22:56:09.178Z" }, - { url = "https://files.pythonhosted.org/packages/3c/43/9c0ff7a2f11615e516c3b058e1e6e8f9614ddeca53faca06da267c48345d/matplotlib-3.10.8-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:b53285e65d4fa4c86399979e956235deb900be5baa7fc1218ea67fbfaeaadd6f", size = 8262481, upload-time = "2025-12-10T22:56:10.885Z" }, - { url = "https://files.pythonhosted.org/packages/6f/ca/e8ae28649fcdf039fda5ef554b40a95f50592a3c47e6f7270c9561c12b07/matplotlib-3.10.8-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:32f8dce744be5569bebe789e46727946041199030db8aeb2954d26013a0eb26b", size = 8151473, upload-time = "2025-12-10T22:56:12.377Z" }, - { url = "https://files.pythonhosted.org/packages/f1/6f/009d129ae70b75e88cbe7e503a12a4c0670e08ed748a902c2568909e9eb5/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4cf267add95b1c88300d96ca837833d4112756045364f5c734a2276038dae27d", size = 9553896, upload-time = "2025-12-10T22:56:14.432Z" }, - { url = "https://files.pythonhosted.org/packages/f5/26/4221a741eb97967bc1fd5e4c52b9aa5a91b2f4ec05b59f6def4d820f9df9/matplotlib-3.10.8-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:2cf5bd12cecf46908f286d7838b2abc6c91cda506c0445b8223a7c19a00df008", size = 9824193, upload-time = "2025-12-10T22:56:16.29Z" }, - { url = "https://files.pythonhosted.org/packages/1f/f3/3abf75f38605772cf48a9daf5821cd4f563472f38b4b828c6fba6fa6d06e/matplotlib-3.10.8-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:41703cc95688f2516b480f7f339d8851a6035f18e100ee6a32bc0b8536a12a9c", size = 9615444, upload-time = "2025-12-10T22:56:18.155Z" }, - { url = "https://files.pythonhosted.org/packages/93/a5/de89ac80f10b8dc615807ee1133cd99ac74082581196d4d9590bea10690d/matplotlib-3.10.8-cp314-cp314-win_amd64.whl", hash = "sha256:83d282364ea9f3e52363da262ce32a09dfe241e4080dcedda3c0db059d3c1f11", size = 8272719, upload-time = "2025-12-10T22:56:20.366Z" }, - { url = "https://files.pythonhosted.org/packages/69/ce/b006495c19ccc0a137b48083168a37bd056392dee02f87dba0472f2797fe/matplotlib-3.10.8-cp314-cp314-win_arm64.whl", hash = "sha256:2c1998e92cd5999e295a731bcb2911c75f597d937341f3030cc24ef2733d78a8", size = 8144205, upload-time = "2025-12-10T22:56:22.239Z" }, - { url = "https://files.pythonhosted.org/packages/68/d9/b31116a3a855bd313c6fcdb7226926d59b041f26061c6c5b1be66a08c826/matplotlib-3.10.8-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:b5a2b97dbdc7d4f353ebf343744f1d1f1cca8aa8bfddb4262fcf4306c3761d50", size = 8305785, upload-time = "2025-12-10T22:56:24.218Z" }, - { url = "https://files.pythonhosted.org/packages/1e/90/6effe8103f0272685767ba5f094f453784057072f49b393e3ea178fe70a5/matplotlib-3.10.8-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:3f5c3e4da343bba819f0234186b9004faba952cc420fbc522dc4e103c1985908", size = 8198361, upload-time = "2025-12-10T22:56:26.787Z" }, - { url = "https://files.pythonhosted.org/packages/d7/65/a73188711bea603615fc0baecca1061429ac16940e2385433cc778a9d8e7/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:5f62550b9a30afde8c1c3ae450e5eb547d579dd69b25c2fc7a1c67f934c1717a", size = 9561357, upload-time = "2025-12-10T22:56:28.953Z" }, - { url = "https://files.pythonhosted.org/packages/f4/3d/b5c5d5d5be8ce63292567f0e2c43dde9953d3ed86ac2de0a72e93c8f07a1/matplotlib-3.10.8-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:495672de149445ec1b772ff2c9ede9b769e3cb4f0d0aa7fa730d7f59e2d4e1c1", size = 9823610, upload-time = "2025-12-10T22:56:31.455Z" }, - { url = "https://files.pythonhosted.org/packages/4d/4b/e7beb6bbd49f6bae727a12b270a2654d13c397576d25bd6786e47033300f/matplotlib-3.10.8-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:595ba4d8fe983b88f0eec8c26a241e16d6376fe1979086232f481f8f3f67494c", size = 9614011, upload-time = "2025-12-10T22:56:33.85Z" }, - { url = "https://files.pythonhosted.org/packages/7c/e6/76f2813d31f032e65f6f797e3f2f6e4aab95b65015924b1c51370395c28a/matplotlib-3.10.8-cp314-cp314t-win_amd64.whl", hash = "sha256:25d380fe8b1dc32cf8f0b1b448470a77afb195438bafdf1d858bfb876f3edf7b", size = 8362801, upload-time = "2025-12-10T22:56:36.107Z" }, - { url = "https://files.pythonhosted.org/packages/5d/49/d651878698a0b67f23aa28e17f45a6d6dd3d3f933fa29087fa4ce5947b5a/matplotlib-3.10.8-cp314-cp314t-win_arm64.whl", hash = "sha256:113bb52413ea508ce954a02c10ffd0d565f9c3bc7f2eddc27dfe1731e71c7b5f", size = 8192560, upload-time = "2025-12-10T22:56:38.008Z" }, - { url = "https://files.pythonhosted.org/packages/f5/43/31d59500bb950b0d188e149a2e552040528c13d6e3d6e84d0cccac593dcd/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:f97aeb209c3d2511443f8797e3e5a569aebb040d4f8bc79aa3ee78a8fb9e3dd8", size = 8237252, upload-time = "2025-12-10T22:56:39.529Z" }, - { url = "https://files.pythonhosted.org/packages/0c/2c/615c09984f3c5f907f51c886538ad785cf72e0e11a3225de2c0f9442aecc/matplotlib-3.10.8-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:fb061f596dad3a0f52b60dc6a5dec4a0c300dec41e058a7efe09256188d170b7", size = 8124693, upload-time = "2025-12-10T22:56:41.758Z" }, - { url = "https://files.pythonhosted.org/packages/91/e1/2757277a1c56041e1fc104b51a0f7b9a4afc8eb737865d63cababe30bc61/matplotlib-3.10.8-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:12d90df9183093fcd479f4172ac26b322b1248b15729cb57f42f71f24c7e37a3", size = 8702205, upload-time = "2025-12-10T22:56:43.415Z" }, - { url = "https://files.pythonhosted.org/packages/04/30/3afaa31c757f34b7725ab9d2ba8b48b5e89c2019c003e7d0ead143aabc5a/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:6da7c2ce169267d0d066adcf63758f0604aa6c3eebf67458930f9d9b79ad1db1", size = 8249198, upload-time = "2025-12-10T22:56:45.584Z" }, - { url = "https://files.pythonhosted.org/packages/48/2f/6334aec331f57485a642a7c8be03cb286f29111ae71c46c38b363230063c/matplotlib-3.10.8-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:9153c3292705be9f9c64498a8872118540c3f4123d1a1c840172edf262c8be4a", size = 8136817, upload-time = "2025-12-10T22:56:47.339Z" }, - { url = "https://files.pythonhosted.org/packages/73/e4/6d6f14b2a759c622f191b2d67e9075a3f56aaccb3be4bb9bb6890030d0a0/matplotlib-3.10.8-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1ae029229a57cd1e8fe542485f27e7ca7b23aa9e8944ddb4985d0bc444f1eca2", size = 8713867, upload-time = "2025-12-10T22:56:48.954Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/63/1b/4be5be87d43d327a0cf4de1a56e86f7f84c89312452406cf122efe2839e6/matplotlib-3.10.9.tar.gz", hash = "sha256:fd66508e8c6877d98e586654b608a0456db8d7e8a546eb1e2600efd957302358", size = 34811233, upload-time = "2026-04-24T00:14:13.539Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/18/6f/340b04986e67aac6f66c5145ce68bf72c64bed30f92c8913499a6e6b8f99/matplotlib-3.10.9-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:77210dce9cb8153dffc967efaae990543392563d5a376d4dd8539bebcb0ed217", size = 8296625, upload-time = "2026-04-24T00:11:43.376Z" }, + { url = "https://files.pythonhosted.org/packages/bb/2f/127081eb83162053ebb9678ceac64220b93a663e0167432566e9c7c82aab/matplotlib-3.10.9-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1e7698ac9868428e84d2c967424803b2472ff7167d9d6590d4204ed775343c3b", size = 8188790, upload-time = "2026-04-24T00:11:46.556Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b7/d8bcec2626c35f96972bff656299fef4578113ea6193c8fdad324710410c/matplotlib-3.10.9-cp310-cp310-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:1aa972116abb4c9d201bf245620b433726cb6856f3bef6a78f776a00f5c92d37", size = 8769389, upload-time = "2026-04-24T00:11:48.959Z" }, + { url = "https://files.pythonhosted.org/packages/12/49/b78e214a527ea732033b7f4d37f7afb504d74ba9d134bd47938230dfb8b1/matplotlib-3.10.9-cp310-cp310-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:ae2f11957b27ce53497dd4d7b235c4d4f1faf383dfb39d0c5beb833bff883294", size = 9589657, upload-time = "2026-04-24T00:11:51.915Z" }, + { url = "https://files.pythonhosted.org/packages/5f/15/5246f7b43beae19c74dfee651d58d6cc8112e06f77adb4e88cc04f2e3a23/matplotlib-3.10.9-cp310-cp310-musllinux_1_2_x86_64.whl", hash = "sha256:b049278ddce116aaa1c1377ebf58adea909132dfce0281cf7e3a1ea9fc2e2c65", size = 9651983, upload-time = "2026-04-24T00:11:54.766Z" }, + { url = "https://files.pythonhosted.org/packages/75/77/5acecfe672ba0fa1b8c0454f69ce155d1e6fc5852fa7206bf9afaf767121/matplotlib-3.10.9-cp310-cp310-win_amd64.whl", hash = "sha256:82834c3c292d24d3a8aae77cd2d20019de69d692a34a970e4fdb8d33e2ea3dda", size = 8199701, upload-time = "2026-04-24T00:11:58.389Z" }, + { url = "https://files.pythonhosted.org/packages/4c/8c/290f021104741fea63769c31494f5324c0cd249bf536a65a4350767b1f22/matplotlib-3.10.9-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:68cfdcede415f7c8f5577b03303dd94526cdb6d11036cecdc205e08733b2d2bb", size = 8306860, upload-time = "2026-04-24T00:12:01.207Z" }, + { url = "https://files.pythonhosted.org/packages/51/18/325cd32ece1120d1da51cc4e4294c6580190699490183fc2fe8cb6d61ec5/matplotlib-3.10.9-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:dfca0129678bd56379db26c52b5d77ed7de314c047492fbdc763aa7501710cfb", size = 8199254, upload-time = "2026-04-24T00:12:04.239Z" }, + { url = "https://files.pythonhosted.org/packages/79/db/e28c1b83e3680740aa78925f5fb2ae4d16207207419ad75ea9fe604f8676/matplotlib-3.10.9-cp311-cp311-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8e436d155fa8a3399dc62683f8f5d0e2e50d25d0144a73edd73f82eec8f4abfb", size = 8777092, upload-time = "2026-04-24T00:12:06.793Z" }, + { url = "https://files.pythonhosted.org/packages/55/fa/3ce7adfe9ba101748f465211660d9c6374c876b671bdb8c2bb6d347e8b94/matplotlib-3.10.9-cp311-cp311-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:56fc0bd271b00025c6edfdc7c2dcd247372c8e1544971d62e1dc7c17367e8bf9", size = 9595691, upload-time = "2026-04-24T00:12:09.706Z" }, + { url = "https://files.pythonhosted.org/packages/36/c4/6960a76686ed668f2c60f84e9799ba4c0d56abdb36b1577b60c1d061d1ec/matplotlib-3.10.9-cp311-cp311-musllinux_1_2_x86_64.whl", hash = "sha256:a5a6104ed666402ba5106d7f36e0e0cdca4e8d7fa4d39708ca88019e2835a2eb", size = 9659771, upload-time = "2026-04-24T00:12:12.766Z" }, + { url = "https://files.pythonhosted.org/packages/7e/0d/271aace3342157c64700c9ff4c59c7b392f3dbab393692e8db6fbe7ab96c/matplotlib-3.10.9-cp311-cp311-win_amd64.whl", hash = "sha256:d730e984eddf56974c3e72b6129c7ca462ac38dc624338f4b0b23eb23ecba00f", size = 8205112, upload-time = "2026-04-24T00:12:15.773Z" }, + { url = "https://files.pythonhosted.org/packages/e2/ee/cb57ad4754f3e7b9174ce6ce66d9205fb827067e48a9f58ac09d7e7d6b77/matplotlib-3.10.9-cp311-cp311-win_arm64.whl", hash = "sha256:51bf0ddbdc598e060d46c16b5590708f81a1624cefbaaf62f6a81bf9285b8c80", size = 8132310, upload-time = "2026-04-24T00:12:18.645Z" }, + { url = "https://files.pythonhosted.org/packages/35/c6/5581e26c72233ebb2a2a6fed2d24fb7c66b4700120b813f51b0555acf0b6/matplotlib-3.10.9-cp312-cp312-macosx_10_13_x86_64.whl", hash = "sha256:f0c3c28d9fbcc1fe7a03be236d73430cf6409c41fb2383a7ac52fe932b072cb1", size = 8319908, upload-time = "2026-04-24T00:12:21.323Z" }, + { url = "https://files.pythonhosted.org/packages/b7/18/4880dd762e40cd360c1bf06e890c5a97b997e91cb324602b1a19950ad5ce/matplotlib-3.10.9-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:41cb28c2bd769aa3e98322c6ab09854cbcc52ab69d2759d681bba3e327b2b320", size = 8216016, upload-time = "2026-04-24T00:12:23.4Z" }, + { url = "https://files.pythonhosted.org/packages/32/91/d024616abdba99e83120e07a20658976f6a343646710760c4a51df126029/matplotlib-3.10.9-cp312-cp312-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:ae20801130378b82d647ff5047c07316295b68dc054ca6b3c13519d0ea624285", size = 8789336, upload-time = "2026-04-24T00:12:26.096Z" }, + { url = "https://files.pythonhosted.org/packages/5c/04/030a2f61ef2158f5e4c259487a92ac877732499fb33d871585d89e03c42d/matplotlib-3.10.9-cp312-cp312-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:6c63ebcd8b4b169eb2f5c200552ae6b8be8999a005b6b507ed76fb8d7d674fe2", size = 9604602, upload-time = "2026-04-24T00:12:29.052Z" }, + { url = "https://files.pythonhosted.org/packages/fc/c2/541e4d09d87bb6b5830fc28b4c887a9a8cf4e1c6cee698a8c05552ae2003/matplotlib-3.10.9-cp312-cp312-musllinux_1_2_x86_64.whl", hash = "sha256:d75d11c949914165976c621b2324f9ef162af7ebf4b057ddf95dd1dba7e5edcf", size = 9670966, upload-time = "2026-04-24T00:12:32.131Z" }, + { url = "https://files.pythonhosted.org/packages/04/a1/4571fc46e7702de8d0c2dc54ad1b2f8e29328dea3ee90831181f7353d93c/matplotlib-3.10.9-cp312-cp312-win_amd64.whl", hash = "sha256:d091f9d758b34aaaaa6331d13574bf01891d903b3dec59bfff458ef7551de5d6", size = 8217462, upload-time = "2026-04-24T00:12:35.226Z" }, + { url = "https://files.pythonhosted.org/packages/4b/d0/2269edb12aa30c13c8bcc9382892e39943ce1d28aab4ec296e0381798e81/matplotlib-3.10.9-cp312-cp312-win_arm64.whl", hash = "sha256:10cc5ce06d10231c36f40e875f3c7e8050362a4ee8f0ee5d29a6b3277d57bb42", size = 8136688, upload-time = "2026-04-24T00:12:37.442Z" }, + { url = "https://files.pythonhosted.org/packages/aa/d3/8d4f6afbecb49fc04e060a57c0fce39ea51cc163a6bd87303ccd698e4fa6/matplotlib-3.10.9-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:b580440f1ff81a0e34122051a3dfabb7e4b7f9e380629929bde0eff9af72165f", size = 8320331, upload-time = "2026-04-24T00:12:39.688Z" }, + { url = "https://files.pythonhosted.org/packages/63/d9/9e14bc7564bf92d5ffa801ae5fac819ce74b925dfb55e3ebde61a3bbad3e/matplotlib-3.10.9-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:b1b745c489cd1a77a0dc1120a05dc87af9798faebc913601feb8c73d89bf2d1e", size = 8216461, upload-time = "2026-04-24T00:12:42.494Z" }, + { url = "https://files.pythonhosted.org/packages/8a/17/4402d0d14ccf1dfc70932600b68097fbbf9c898a4871d2cbbe79c7801a32/matplotlib-3.10.9-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:8f3bcac1ca5ed000a6f4337d47ba67dfddf37ed6a46c15fd7f014997f7bf865f", size = 8790091, upload-time = "2026-04-24T00:12:44.789Z" }, + { url = "https://files.pythonhosted.org/packages/3e/0b/322aeec06dd9b91411f92028b37d447342770a24392aa4813e317064dad5/matplotlib-3.10.9-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:7a8d66a55def891c33147ba3ba9bfcabf0b526a43764c818acbb4525e5ed0838", size = 9605027, upload-time = "2026-04-24T00:12:47.583Z" }, + { url = "https://files.pythonhosted.org/packages/74/88/5f13482f55e7b00bcfc09838b093c2456e1379978d2a146844aae05350ad/matplotlib-3.10.9-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:d843374407c4017a6403b59c6c81606773d136f3259d5b6da3131bc814542cc2", size = 9671269, upload-time = "2026-04-24T00:12:50.878Z" }, + { url = "https://files.pythonhosted.org/packages/c5/e0/0840fd2f93da988ec660b8ad1984abe9f25d2aed22a5e394ff1c68c88307/matplotlib-3.10.9-cp313-cp313-win_amd64.whl", hash = "sha256:f4399f64b3e94cd500195490972ae1ee81170df1636fa15364d157d5bdd7b921", size = 8217588, upload-time = "2026-04-24T00:12:53.784Z" }, + { url = "https://files.pythonhosted.org/packages/47/b9/d706d06dd605c49b9f83a2aed8c13e3e5db70697d7a80b7e3d7915de6b17/matplotlib-3.10.9-cp313-cp313-win_arm64.whl", hash = "sha256:ba7b3b8ef09eab7df0e86e9ae086faa433efbfbdb46afcb3aa16aabf779469a8", size = 8136913, upload-time = "2026-04-24T00:12:56.501Z" }, + { url = "https://files.pythonhosted.org/packages/9b/45/6e32d96978264c8ca8c4b1010adb955a1a49cfaf314e212bbc8908f04a61/matplotlib-3.10.9-cp313-cp313t-macosx_10_13_x86_64.whl", hash = "sha256:09218df8a93712bd6ea133e83a153c755448cf7868316c531cffcc43f69d1cc9", size = 8368019, upload-time = "2026-04-24T00:12:58.896Z" }, + { url = "https://files.pythonhosted.org/packages/86/0a/c8e3d3bba245f0f7fc424937f8ff7ef77291a36af3edb97ccd78aa93d84f/matplotlib-3.10.9-cp313-cp313t-macosx_11_0_arm64.whl", hash = "sha256:82368699727bfb7b0182e1aa13082e3c08e092fa1a25d3e1fd92405bff96f6d4", size = 8264645, upload-time = "2026-04-24T00:13:01.406Z" }, + { url = "https://files.pythonhosted.org/packages/3d/aa/5bf5a14fe4fed73a4209a155606f8096ff797aad89c6c35179026571133e/matplotlib-3.10.9-cp313-cp313t-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3225f4e1edcb8c86c884ddf79ebe20ecd0a67d30188f279897554ccd8fded4dc", size = 8802194, upload-time = "2026-04-24T00:13:03.702Z" }, + { url = "https://files.pythonhosted.org/packages/dd/5e/b4be852d6bba6fd15893fadf91ff26ae49cb91aac789e95dde9d342e664f/matplotlib-3.10.9-cp313-cp313t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:de2445a0c6690d21b7eb6ce071cebad6d40a2e9bdf10d039074a96ba19797b99", size = 9622684, upload-time = "2026-04-24T00:13:06.647Z" }, + { url = "https://files.pythonhosted.org/packages/4c/3d/ed428c971139112ef730f62770654d609467346d09d4b62617e1afd68a5a/matplotlib-3.10.9-cp313-cp313t-musllinux_1_2_x86_64.whl", hash = "sha256:b2b9516251cb89ff618d757daec0e2ed1bf21248013844a853d87ef85ab3081d", size = 9680790, upload-time = "2026-04-24T00:13:10.009Z" }, + { url = "https://files.pythonhosted.org/packages/e7/09/052e884aaf2b985c63cb79f715f1d5b6a3eaa7de78f6a52b9dbc077d5b53/matplotlib-3.10.9-cp313-cp313t-win_amd64.whl", hash = "sha256:e9fae004b941b23ff2edcf1567a857ed77bafc8086ffa258190462328434faf8", size = 8287571, upload-time = "2026-04-24T00:13:13.087Z" }, + { url = "https://files.pythonhosted.org/packages/f4/38/ae27288e788c35a4250491422f3db7750366fc8c97d6f36fbdecfc1f5518/matplotlib-3.10.9-cp313-cp313t-win_arm64.whl", hash = "sha256:6b63d9c7c769b88ab81e10dc86e4e0607cf56817b9f9e6cf24b2a5f1693b8e38", size = 8188292, upload-time = "2026-04-24T00:13:15.546Z" }, + { url = "https://files.pythonhosted.org/packages/d6/e6/3bd8afd04949f02eabc1c17115ea5255e19cacd4d06fc5abdde4eeb0052c/matplotlib-3.10.9-cp314-cp314-macosx_10_13_x86_64.whl", hash = "sha256:172db52c9e683f5d12eaf57f0f54834190e12581fe1cc2a19595a8f5acb4e77d", size = 8321276, upload-time = "2026-04-24T00:13:18.318Z" }, + { url = "https://files.pythonhosted.org/packages/41/86/86231232fff41c9f8e4a1a7d7a597d349a02527109c3af7d618366122139/matplotlib-3.10.9-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:97e35e8d39ccc85859095e01a53847432ba9a53ddf7986f7a54a11b73d0e143f", size = 8218218, upload-time = "2026-04-24T00:13:20.974Z" }, + { url = "https://files.pythonhosted.org/packages/85/8f/becc9722cafc64f5d2eb0b7c1bf5f585271c618a45dbd8fabeb021f898b6/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:aba1615dabe83188e19d4f75a253c6a08423e04c1425e64039f800050a69de6b", size = 9608145, upload-time = "2026-04-24T00:13:23.228Z" }, + { url = "https://files.pythonhosted.org/packages/32/5d/f7e914f7d9325abff4057cee62c0fa70263683189f774473cbfb534cd13b/matplotlib-3.10.9-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:34cf8167e023ad956c15f36302911d5406bd99a9862c1a8499ea6f7c0e015dc2", size = 9885085, upload-time = "2026-04-24T00:13:25.849Z" }, + { url = "https://files.pythonhosted.org/packages/a5/fd/fa69f2221534e80cc5772ac2b7d222011a2acafc2ec7216d5dd174c864ae/matplotlib-3.10.9-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:59476c6d29d612b8e9bb6ce8c5b631be6ba8f9e3a2421f22a02b192c7dd28716", size = 9672358, upload-time = "2026-04-24T00:13:28.906Z" }, + { url = "https://files.pythonhosted.org/packages/ab/1a/5a4f747a8b271cbb024946d2dd3c913ab5032ba430626f8c3528ada96b4b/matplotlib-3.10.9-cp314-cp314-win_amd64.whl", hash = "sha256:336b9acc64d309063126edcdaca00db9373af3c476bb94388fe9c5a53ad13e6f", size = 8349970, upload-time = "2026-04-24T00:13:31.904Z" }, + { url = "https://files.pythonhosted.org/packages/64/dc/95d60ecaefe30680a154b52ea96ab4b0dab547f1fd6aa12f5fb655e89cae/matplotlib-3.10.9-cp314-cp314-win_arm64.whl", hash = "sha256:2dc9477819ffd78ad12a20df1d9d6a6bd4fec6aaa9072681465fddca052f1456", size = 8272785, upload-time = "2026-04-24T00:13:34.511Z" }, + { url = "https://files.pythonhosted.org/packages/70/a0/005d68bc8b8418300ce6591f18586910a8526806e2ab663933d9f20a41e9/matplotlib-3.10.9-cp314-cp314t-macosx_10_13_x86_64.whl", hash = "sha256:da4e09638420548f31c354032a6250e473c68e5a4e96899b4844cf39ddea23fe", size = 8367999, upload-time = "2026-04-24T00:13:36.962Z" }, + { url = "https://files.pythonhosted.org/packages/22/05/1236cc9290be70b2498af20ca348add76e3fffe7f67b477db5133a84f3ea/matplotlib-3.10.9-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:345f6f68ecc8da0ca56fad2ea08fde1a115eda530079eca185d50a7bc3e146c6", size = 8264543, upload-time = "2026-04-24T00:13:39.851Z" }, + { url = "https://files.pythonhosted.org/packages/cd/c2/071f5a5ff6c5bd63aaaf2f45c811d9bf2ced94bde188d9e1a519e21d0cba/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:4edcfbd8565339aa62f1cd4012f7180926fdbe71850f7b0d3c379c175cd6b66c", size = 9622800, upload-time = "2026-04-24T00:13:42.296Z" }, + { url = "https://files.pythonhosted.org/packages/95/57/da7d1f10a85624b9e7db68e069dd94e58dc41dbf9463c5921632ecbe3661/matplotlib-3.10.9-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6be157fe17fc37cb95ac1d7374cf717ce9259616edec911a78d9d26dae8522d4", size = 9888561, upload-time = "2026-04-24T00:13:45.026Z" }, + { url = "https://files.pythonhosted.org/packages/67/b2/ef8d6bb59b0edb6c16c968b70f548aa13b54348972def5aa6ac85df67145/matplotlib-3.10.9-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:4e42042d54db34fda4e95a7bd3e5789c2a995d2dad3eb8850232ee534092fbbf", size = 9680884, upload-time = "2026-04-24T00:13:48.066Z" }, + { url = "https://files.pythonhosted.org/packages/61/1c/d21bfeb9931881ebe96bcfcff27c7ae4b160ae0ec291a714c42641a56d75/matplotlib-3.10.9-cp314-cp314t-win_amd64.whl", hash = "sha256:c27df8b3848f32a83d1767566595e43cfaa4460380974da06f4279a7ec143c39", size = 8432333, upload-time = "2026-04-24T00:13:51.008Z" }, + { url = "https://files.pythonhosted.org/packages/78/23/92493c3e6e1b635ccfff146f7b99e674808787915420373ac399283764c2/matplotlib-3.10.9-cp314-cp314t-win_arm64.whl", hash = "sha256:a49f1eadc84ca85fd72fa4e89e70e61bf86452df6f971af04b12c60761a0772c", size = 8324785, upload-time = "2026-04-24T00:13:53.633Z" }, + { url = "https://files.pythonhosted.org/packages/2c/2b/0e92ad0ac446633f928a1563db4aa8add407e1924faf0ded5b95b35afb27/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_10_15_x86_64.whl", hash = "sha256:1872fb212a05b729e649754a72d5da61d03e0554d76e80303b6f83d1d2c0552b", size = 8293058, upload-time = "2026-04-24T00:13:56.339Z" }, + { url = "https://files.pythonhosted.org/packages/4b/23/74682fd369f5299ceda438fea2a0662e6383b85c9383fb9cdfcf04713e07/matplotlib-3.10.9-pp310-pypy310_pp73-macosx_11_0_arm64.whl", hash = "sha256:985f2238880e2e69093f588f5fe2e46771747febf0649f3cf7f7b7480875317f", size = 8186627, upload-time = "2026-04-24T00:13:58.623Z" }, + { url = "https://files.pythonhosted.org/packages/ca/e8/368aab88f3c4cd8992800f31abfe0670c3e47540ba20a97e9fdbcde594b3/matplotlib-3.10.9-pp310-pypy310_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:6640f75af2c6148293caa0a2b39dd806a492dd66c8a8b04035813e33d0fd2585", size = 8764117, upload-time = "2026-04-24T00:14:01.684Z" }, + { url = "https://files.pythonhosted.org/packages/63/e2/9f66ca6a651a52abfe0d4964ce01439ed34f3f1e119de10ff3a07f403043/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_10_15_x86_64.whl", hash = "sha256:42fb814efabe95c06c1994d8ab5a8385f43a249e23badd3ba931d4308e5bca20", size = 8304420, upload-time = "2026-04-24T00:14:04.57Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e8/467c03568218792906aa87b5e7bb379b605e056ed0c74fe00c051786d925/matplotlib-3.10.9-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:f76e640a5268850bfda54b5131b1b1941cc685e42c5fa98ed9f2d64038308cba", size = 8197981, upload-time = "2026-04-24T00:14:07.233Z" }, + { url = "https://files.pythonhosted.org/packages/6f/87/afead29192170917537934c6aff4b008c805fff7b1ccea0c79120d96beda/matplotlib-3.10.9-pp311-pypy311_pp73-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:3fc0364dfbe1d07f6d15c5ebd0c5bf89e126916e5a8667dd4a7a6e84c36653d4", size = 8774002, upload-time = "2026-04-24T00:14:09.816Z" }, ] [[package]] name = "matplotlib-inline" -version = "0.2.1" +version = "0.2.2" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "traitlets" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/c7/74/97e72a36efd4ae2bccb3463284300f8953f199b5ffbc04cbbb0ec78f74b1/matplotlib_inline-0.2.1.tar.gz", hash = "sha256:e1ee949c340d771fc39e241ea75683deb94762c8fa5f2927ec57c83c4dffa9fe", size = 8110, upload-time = "2025-10-23T09:00:22.126Z" } +sdist = { url = "https://files.pythonhosted.org/packages/bd/c0/9f7c9a46090390368a4d7bcb76bb87a4a36c421e4c0792cdb53486ffac7a/matplotlib_inline-0.2.2.tar.gz", hash = "sha256:72f3fe8fce36b70d4a5b612f899090cd0401deddc4ea90e1572b9f4bfb058c79", size = 8150, upload-time = "2026-05-08T17:33:33.49Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/af/33/ee4519fa02ed11a94aef9559552f3b17bb863f2ecfe1a35dc7f548cde231/matplotlib_inline-0.2.1-py3-none-any.whl", hash = "sha256:d56ce5156ba6085e00a9d54fead6ed29a9c47e215cd1bba2e976ef39f5710a76", size = 9516, upload-time = "2025-10-23T09:00:20.675Z" }, + { url = "https://files.pythonhosted.org/packages/41/09/5b161152e2d90f7b87f781c2e1267494aef9c32498df793f73ad0a0a494a/matplotlib_inline-0.2.2-py3-none-any.whl", hash = "sha256:3c821cf1c209f59fb2d2d64abbf5b23b67bcb2210d663f9918dd851c6da1fcf6", size = 9534, upload-time = "2026-05-08T17:33:32.055Z" }, ] [[package]] name = "maturin" -version = "1.13.1" +version = "1.13.3" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "tomli", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/39/16/b284a7bc4af3dd87717c784278c1b8cb18606ad1f6f7a671c47bfd9c3df0/maturin-1.13.1.tar.gz", hash = "sha256:9a87ff3b8e4d1c6eac33ebfe8e261e8236516d98d45c0323550621819b5a1a2f", size = 340369, upload-time = "2026-04-09T15:14:07.026Z" } +sdist = { url = "https://files.pythonhosted.org/packages/9c/1c/612d23d33ec21b9ae7ece7b3f0dd5f9dfd57b4009e9d2938165869ebd6ae/maturin-1.13.3.tar.gz", hash = "sha256:771e1e9e71a278e56db01552e0d1acfd1464259f9575b6e72842f893cd299079", size = 357934, upload-time = "2026-05-11T07:43:39.027Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/43/4d/a23fc95be881aa8c7a6ea353410417872e4d7065df03d7f3db8f0dbed4a7/maturin-1.13.1-py3-none-linux_armv6l.whl", hash = "sha256:416e4e01cb88b798e606ee43929df897e42c1647b722ef68283816cca99a8742", size = 10102444, upload-time = "2026-04-09T15:13:48.393Z" }, - { url = "https://files.pythonhosted.org/packages/a6/1e/65c385d65bae95cf04895d52f39dbed8b1453ae55da2903d252ade40a774/maturin-1.13.1-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:72888e87819ce546d0d2df900e4b385e4ef299077d92ee37b48923a5602dae94", size = 19576043, upload-time = "2026-04-09T15:14:08.685Z" }, - { url = "https://files.pythonhosted.org/packages/8f/13/f6bc868d0bfecd9314870b97f530a167e31f7878ac4945c78245c6eef69c/maturin-1.13.1-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:98b5fcf1a186c217830a8295ecc2989c6b1cf50945417adfc15252107b9475b7", size = 10117339, upload-time = "2026-04-09T15:13:40.559Z" }, - { url = "https://files.pythonhosted.org/packages/51/58/279e081305c11c1c1c4fccacf77df8959646c5d4de7a57ec7e787653e270/maturin-1.13.1-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:3da18cccf2f683c0977bff9146a0908d6ffce836d600665736ac01679f588cb9", size = 10139689, upload-time = "2026-04-09T15:13:38.291Z" }, - { url = "https://files.pythonhosted.org/packages/00/94/69391af5396c6aab723932240803f49e5f3de3dd7c57d32f02d237a0ce32/maturin-1.13.1-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:6b1e5916a253243e8f5f9e847b62bbc98420eec48c9ce2e2e8724c6da89d359b", size = 10551141, upload-time = "2026-04-09T15:13:42.887Z" }, - { url = "https://files.pythonhosted.org/packages/9e/bf/4edac2667b49e3733438062ae416413b8fc8d42e1bd499ba15e1fb02fc55/maturin-1.13.1-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:dc91031e0619c1e28730279ef9ee5f106c9b9ec806b013f888676b242f892eb7", size = 9983094, upload-time = "2026-04-09T15:13:56.868Z" }, - { url = "https://files.pythonhosted.org/packages/79/94/a6d651cfe8fc6bf2e892c90e3cdbb25c06d81c9115140d03ea1a68a97575/maturin-1.13.1-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:001741c6cff56aa8ea59a0d78ae990c0550d0e3e82b00b683eedb4158a8ef7e6", size = 9949980, upload-time = "2026-04-09T15:13:59.185Z" }, - { url = "https://files.pythonhosted.org/packages/b5/d1/82c067464f848e38af9910bce55eb54302b1c1284a279d515dbfcf5994f5/maturin-1.13.1-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:01c845825c917c07c1d0b2c9032c59c16a7d383d1e649a46481d3e5693c2750f", size = 13186276, upload-time = "2026-04-09T15:13:45.725Z" }, - { url = "https://files.pythonhosted.org/packages/7c/f4/25367baf1025580f047f9b37598bb3fadc416e24536afd4f28e190335c73/maturin-1.13.1-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:f69093ed4a0e6464e52a7fc26d714f859ce15630ec8070743398c6bf41f38a9e", size = 10891837, upload-time = "2026-04-09T15:13:35.68Z" }, - { url = "https://files.pythonhosted.org/packages/af/be/caafad8ce74974b7deafdf144d12f758993dfea4c66c9905b138f51a7792/maturin-1.13.1-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:c1490584f3c70af45466ee99065b49e6657ebdccac6b10571bb44681309c9396", size = 10351032, upload-time = "2026-04-09T15:14:01.632Z" }, - { url = "https://files.pythonhosted.org/packages/66/0e/970a721d27cfa410e8bfa0a1e32e6ef52cb8169692110a5fdabe1af3f570/maturin-1.13.1-py3-none-win32.whl", hash = "sha256:c6a720b252c99de072922dbe4432ab19662b6f80045b0355fec23bdfccb450da", size = 8855465, upload-time = "2026-04-09T15:13:51.122Z" }, - { url = "https://files.pythonhosted.org/packages/88/70/7c1e0d65fa147d5479055a171541c82b8cdfc1c825d85a82240470f14176/maturin-1.13.1-py3-none-win_amd64.whl", hash = "sha256:a2017d2281203d0c6570240e7d746564d766d756105823b7de68bda6ae722711", size = 10230471, upload-time = "2026-04-09T15:13:53.89Z" }, - { url = "https://files.pythonhosted.org/packages/c5/2a/afe0193b673a79ffd2e01ad999511b7e9e6b49af02bb3759d82a78c3043d/maturin-1.13.1-py3-none-win_arm64.whl", hash = "sha256:2839024dcd65776abb4759e5bca29941971e095574162a4d335191da4be9ff24", size = 8905575, upload-time = "2026-04-09T15:14:03.891Z" }, + { url = "https://files.pythonhosted.org/packages/71/66/18c2aaac0b2a5dea9f1db5984ce83b905ad205cfc7c02d0091e707c0c2e7/maturin-1.13.3-py3-none-linux_armv6l.whl", hash = "sha256:3cc13929ca82aefa4adbf0f2c35419369796213c6fb0eb24e914945f50ef5d8c", size = 10190971, upload-time = "2026-05-11T07:43:10.431Z" }, + { url = "https://files.pythonhosted.org/packages/bc/71/26a988d092e4fd6a9523d46d44400a46cad7cdf3fd206ce702240c748aee/maturin-1.13.3-py3-none-macosx_10_12_x86_64.macosx_11_0_arm64.macosx_10_12_universal2.whl", hash = "sha256:53b08bd075649ce96513ad9abf241a43cb685ed6e9e7790f8dbc2d66e95d8323", size = 19716714, upload-time = "2026-05-11T07:43:36.911Z" }, + { url = "https://files.pythonhosted.org/packages/82/5c/f3fd0e184255d9fc7e272c62af3dfa84c617b2577ef83af9ce615f5279cc/maturin-1.13.3-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:4cd478e6e4c56251e48ed079b8efd55b30bc5c09cf695a1bdafaeb582ee735a0", size = 10194726, upload-time = "2026-05-11T07:43:07.05Z" }, + { url = "https://files.pythonhosted.org/packages/a9/e1/f4edb69fb647b77c4769a9bfd4d6fb62961e653d164bc277ecdffac3ab61/maturin-1.13.3-py3-none-manylinux_2_12_i686.manylinux2010_i686.musllinux_1_1_i686.whl", hash = "sha256:a2675e25f313034ae6f57388cf14818f87d8961c4a96795287f3e155f59beb11", size = 10172781, upload-time = "2026-05-11T07:43:40.796Z" }, + { url = "https://files.pythonhosted.org/packages/c7/7d/a1be934690cdcc3c6609769ceaad322ab7501c2ee5bafcac1b14d609e403/maturin-1.13.3-py3-none-manylinux_2_12_x86_64.manylinux2010_x86_64.musllinux_1_1_x86_64.whl", hash = "sha256:4667ef609ab446c1b5e0bfe4f9fb99699ab6d8548433f8d1a684256e0b67217f", size = 10682670, upload-time = "2026-05-11T07:43:13.132Z" }, + { url = "https://files.pythonhosted.org/packages/18/f5/372ae19b72ce8f6e37e5864ae4dc5b252ee9fce0619ccc3aa366aa3a7f97/maturin-1.13.3-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.musllinux_1_1_aarch64.whl", hash = "sha256:3db93337ed97e60ffc878aa8b493cd7ae44d3a5e1a37256db3a4491f57565018", size = 10060363, upload-time = "2026-05-11T07:43:21.107Z" }, + { url = "https://files.pythonhosted.org/packages/cb/5b/c68340cca09368af0df80965dfabed4234205a492a93da00793c7b9aae20/maturin-1.13.3-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.musllinux_1_1_armv7l.whl", hash = "sha256:1cc0a110b224ca90406b668a3e3c1f5a515062e59e26292f6dbaf5fd4909c6f3", size = 10017551, upload-time = "2026-05-11T07:43:33.916Z" }, + { url = "https://files.pythonhosted.org/packages/28/1e/f90fb2b000bad9e6d850cd5afb88b2f1e2a279cfb4de02ea40078484690e/maturin-1.13.3-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.musllinux_1_1_ppc64le.whl", hash = "sha256:c00ea6428dea17bf616fe93770837634454b28c2de1a876e42ef8036c616079a", size = 13301712, upload-time = "2026-05-11T07:43:26.492Z" }, + { url = "https://files.pythonhosted.org/packages/be/58/1670f68a8f04ccd7b90df11047bd9a046585310e84e1967cc9849cd1c5a3/maturin-1.13.3-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:49fd6ab08da28098ccf37afca24cdba72376ba9c1eedf9dd25ff82ed771961ff", size = 10946765, upload-time = "2026-05-11T07:43:16.135Z" }, + { url = "https://files.pythonhosted.org/packages/4b/ac/00c955c2ef134817b1a7bdaa76b0309e9c5291eb17d9ff88069eecd08bc2/maturin-1.13.3-py3-none-manylinux_2_31_riscv64.musllinux_1_1_riscv64.whl", hash = "sha256:b6741d7bf4af97da937528fd1e523c6ab54f53d9a21870fa735d6e67fd88e273", size = 10388661, upload-time = "2026-05-11T07:43:18.727Z" }, + { url = "https://files.pythonhosted.org/packages/97/c6/cbf8a51dde19c19aeba0d9b075095a2effb9b31fd312b1aae3ac79f8aea2/maturin-1.13.3-py3-none-win32.whl", hash = "sha256:0ef257e692cc756c87af5bea95ddfe7d3ac49d3376a7a87f728d63f06e7b6f8b", size = 8901838, upload-time = "2026-05-11T07:43:23.76Z" }, + { url = "https://files.pythonhosted.org/packages/a1/ff/c6a50a59dc8313097d43ac5f4d74df6a500c8cb62b0dc9e054f53e203a48/maturin-1.13.3-py3-none-win_amd64.whl", hash = "sha256:def4a435ea9d2ee93b18ba579dc8c9cf898889a66f312cd379b5e374ec3e3ad6", size = 10340801, upload-time = "2026-05-11T07:43:29.239Z" }, + { url = "https://files.pythonhosted.org/packages/6c/93/e32e79333f0902ba292b996f504f5f06be59587f7d02ab8d5ed1e3066445/maturin-1.13.3-py3-none-win_arm64.whl", hash = "sha256:2389fe92d017cea9d94e521fa0175314a4c52f79a1057b901fbc9f8686ef7d0b", size = 9706562, upload-time = "2026-05-11T07:43:31.743Z" }, ] [[package]] @@ -2460,11 +2426,9 @@ version = "3.6.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.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", - "python_full_version == '3.11.*' 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.12' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin')", - "(python_full_version == '3.11.*' and platform_machine != 'x86_64') or (python_full_version == '3.11.*' 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/6a/51/63fe664f3908c97be9d2e4f1158eb633317598cfa6e1fc14af5383f17512/networkx-3.6.1.tar.gz", hash = "sha256:26b7c357accc0c8cde558ad486283728b65b6a95d85ee1cd66bafab4c8168509", size = 2517025, upload-time = "2025-12-08T17:02:39.908Z" } wheels = [ @@ -2580,11 +2544,9 @@ version = "2.4.4" 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.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", - "python_full_version == '3.11.*' 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.12' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin')", - "(python_full_version == '3.11.*' and platform_machine != 'x86_64') or (python_full_version == '3.11.*' 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 = [ @@ -2663,7 +2625,7 @@ wheels = [ [[package]] name = "nvmath-python" -version = "0.8.0" +version = "0.9.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cuda-bindings", marker = "python_full_version >= '3.11'" }, @@ -2673,18 +2635,21 @@ dependencies = [ { name = "pywin32", marker = "python_full_version >= '3.11' and sys_platform == 'win32'" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/68/7c/f3baa8a3c0065a6f585b268b7ce81ee211bf598d3a30671a0838b5344623/nvmath_python-0.8.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:c5427d79f254065e8b4177b62ec50533b6ef6f057ca2fcd7249dad01b6c54aa4", size = 4065063, upload-time = "2026-01-16T19:46:12.172Z" }, - { url = "https://files.pythonhosted.org/packages/8f/40/e92561ed96ddb5e6f1465cc6afe6f8253265f1aabcf3de39b95035e4aedc/nvmath_python-0.8.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:4a2da7fe00ed9396504f910223d988f8218639564d2adf51fb9a1ffdd49a3413", size = 4292532, upload-time = "2026-01-16T19:48:01.929Z" }, - { url = "https://files.pythonhosted.org/packages/34/84/fd5fa264ec794af22d75bfad85217ee76cfa128f977566b55275a5745c1a/nvmath_python-0.8.0-cp310-cp310-win_amd64.whl", hash = "sha256:f7e55d101b1a6ee8ee39e220e1adccc1a85d5901c8976e93c099d0179c06888f", size = 3318752, upload-time = "2026-01-16T19:49:33.604Z" }, - { url = "https://files.pythonhosted.org/packages/57/12/8869f588c74bc0e558b4bb24a7db09ef188ba7fb946d51bd7fb530fc521f/nvmath_python-0.8.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:16ebe97eb5f99ef297a58e76068eff4740aed56e2313b70155f3bc262210c7be", size = 4069113, upload-time = "2026-01-16T19:45:22.442Z" }, - { url = "https://files.pythonhosted.org/packages/d8/3b/578ac0c6942d6cc123dffab6b50eb3e2918fab8a9c3c9a5f23b23ca88c92/nvmath_python-0.8.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:4bad916792d80e23562d151e2a74088f0453f7a55750b6d06742bfb3eb7e3769", size = 4300058, upload-time = "2026-01-16T19:47:37.895Z" }, - { url = "https://files.pythonhosted.org/packages/90/85/eb2222a3e19ec168e9f4bec5d732b0010c8f69bc5b494eecaaf49f032cec/nvmath_python-0.8.0-cp311-cp311-win_amd64.whl", hash = "sha256:f75beb448e318f71b9518b1905d6eb754fe64367161baef3390e213ea7884271", size = 3322171, upload-time = "2026-01-16T19:49:09.896Z" }, - { url = "https://files.pythonhosted.org/packages/e9/64/dc6b1265fdb58b4b07dc09e3a48fd7a1a53b2a4ef156ee81598aa85d6197/nvmath_python-0.8.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:bc3f4723035d120be41cf1ec62478c48e3abcdd8518a607b745681049539eb90", size = 3957097, upload-time = "2026-01-16T19:44:31.268Z" }, - { url = "https://files.pythonhosted.org/packages/9f/f6/75c00b0e82b477b2c1275f2a44e920e2b68d07f75fbbbe589b888c410845/nvmath_python-0.8.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:d070b76f09a4bf03e81822cf6036492ca386a9f8d591be65389bd35a77424809", size = 4172184, upload-time = "2026-01-16T19:47:14.908Z" }, - { url = "https://files.pythonhosted.org/packages/9f/64/119f074844bfc8bccb3ba130a4850ed9e7e46e140d7c701cdf0bdba72108/nvmath_python-0.8.0-cp312-cp312-win_amd64.whl", hash = "sha256:a3b98cb5cc6632f502a94c0378445894e40ed8f685dcf340592d245a7420260a", size = 3206989, upload-time = "2026-01-16T19:48:47.188Z" }, - { url = "https://files.pythonhosted.org/packages/f9/f2/5da4507fd9d883a2f780bfdd6854e8b2f9116c45c080b4c7a959d514c3ae/nvmath_python-0.8.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d63b7a4ff9d06e2398ccbd8d953d54b24f523a1ef45423bf7eab133bbcfda849", size = 3950487, upload-time = "2026-01-16T19:43:38.171Z" }, - { url = "https://files.pythonhosted.org/packages/86/02/6a55394057a43aaf26aa3520d40f0df6bbe31bf6e22c6abf855865c4fcc6/nvmath_python-0.8.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:cf98ec9cbe4b93a316455693861cfcf0caca3064b3f93cccc93ebb704d82507e", size = 4204475, upload-time = "2026-01-16T19:46:50.254Z" }, - { url = "https://files.pythonhosted.org/packages/c3/a8/d73f63ddbbeab859bc241cd092374c1297fbebbc87bb1bf372a81d4fdcbf/nvmath_python-0.8.0-cp313-cp313-win_amd64.whl", hash = "sha256:a996a319fc4ac816d319f893bf52fb3fcd5578ab7ca53148d3d5358846a4a71b", size = 3160626, upload-time = "2026-01-16T19:48:24.458Z" }, + { url = "https://files.pythonhosted.org/packages/25/a1/3571648e040c5c3bdc96d8758bf879247bbf1bbd69e55546990feed3fbed/nvmath_python-0.9.0-cp310-cp310-manylinux_2_28_aarch64.whl", hash = "sha256:b9bd878f6da62f1f43394f234dc364d3007cab55543f7546821832e55d7a7b8e", size = 4313451, upload-time = "2026-04-22T02:28:45.816Z" }, + { url = "https://files.pythonhosted.org/packages/42/9f/31f57b0453641153754da133d45408cad43beb2f61d7739845e7f1de8aac/nvmath_python-0.9.0-cp310-cp310-manylinux_2_28_x86_64.whl", hash = "sha256:1e0c91f275b5e025b84bbe6e22dbbbc5305f5df0a00803e8bfb68b4cac3bb241", size = 4549673, upload-time = "2026-04-22T02:30:56.266Z" }, + { url = "https://files.pythonhosted.org/packages/23/23/4b085761b1c785a2449b309d5fb067616b19cdc064bc7d70e3dbdd8130de/nvmath_python-0.9.0-cp310-cp310-win_amd64.whl", hash = "sha256:cc66c433b8bc0fd1de287af8a1ae3bf1b66ef2c1b259e218855f22c194c8e262", size = 3581496, upload-time = "2026-04-22T02:25:04.551Z" }, + { url = "https://files.pythonhosted.org/packages/e4/2d/fc7d215d45771c89df7f98ac7df350b8f56e70b8119caaa16d150fb3459f/nvmath_python-0.9.0-cp311-cp311-manylinux_2_28_aarch64.whl", hash = "sha256:bd5d2e49ddb120e41c663db4a0599f78ac359f7c59273062d9f8537e2d0d9933", size = 4316206, upload-time = "2026-04-22T02:27:57.972Z" }, + { url = "https://files.pythonhosted.org/packages/51/07/6a0366322987c0c69159479a102b20c6f51b9c0f88ef6cd6219c4b9ee08d/nvmath_python-0.9.0-cp311-cp311-manylinux_2_28_x86_64.whl", hash = "sha256:8517babb36887ef367d75db8bddced90a08c8cc4c360cf19052eaff9f4481149", size = 4563973, upload-time = "2026-04-22T02:30:33.39Z" }, + { url = "https://files.pythonhosted.org/packages/b8/49/02beb28e812f1d99b2cc90ce67c73ae43320ee8d4d491b4544e5d37fdac3/nvmath_python-0.9.0-cp311-cp311-win_amd64.whl", hash = "sha256:c82f323bbe5395f49961294c99c0516ecb0877ad7556bc8a756f26ea0e42ee78", size = 3584232, upload-time = "2026-04-22T02:24:41.372Z" }, + { url = "https://files.pythonhosted.org/packages/c8/ca/961d01ad05a502123fb9c64bc7d5bf22642faaa024bf6863ac71e58446d3/nvmath_python-0.9.0-cp312-cp312-manylinux_2_28_aarch64.whl", hash = "sha256:c329362de9481335895be4edd326be41092bd626bd939e6b5bf0803ec53ac4f8", size = 4194042, upload-time = "2026-04-22T02:27:11.239Z" }, + { url = "https://files.pythonhosted.org/packages/a8/06/3b3f6851557496d9fb617f2466fed8285fe0a3c37cc5927a108ebf5d688f/nvmath_python-0.9.0-cp312-cp312-manylinux_2_28_x86_64.whl", hash = "sha256:13d9e03d55936710634dceeec89101c6c0203a922bf830165093754271fb14eb", size = 4420715, upload-time = "2026-04-22T02:30:10.735Z" }, + { url = "https://files.pythonhosted.org/packages/94/71/edab7d1070341bf9849531109c6834d51f5cfff4275395b9f341db50b0d1/nvmath_python-0.9.0-cp312-cp312-win_amd64.whl", hash = "sha256:53cf7f22dd0a44d1462070387a331f8bc604126dd5f7c71dfa8dd416c5e7029e", size = 3481062, upload-time = "2026-04-22T02:24:21.366Z" }, + { url = "https://files.pythonhosted.org/packages/a4/68/0e894cb738562adf0b08be03d7b330d81f1b567b86ac5f3832d7736eb624/nvmath_python-0.9.0-cp313-cp313-manylinux_2_28_aarch64.whl", hash = "sha256:d951d3968c376524e3387dbfeacd7cafe6da9a8684451eb699d493fdabd6e7b4", size = 4194109, upload-time = "2026-04-22T02:26:23.692Z" }, + { url = "https://files.pythonhosted.org/packages/79/23/31fd51761bbe66b999bdf845e06ddf47cf576a478a871d60d50d37a542b7/nvmath_python-0.9.0-cp313-cp313-manylinux_2_28_x86_64.whl", hash = "sha256:c06a388fc5ce53975db86a0b3e433299fdb2fbdf624cae4e60a4af45bd82c7f9", size = 4452262, upload-time = "2026-04-22T02:29:48.213Z" }, + { url = "https://files.pythonhosted.org/packages/69/f3/da5825468bb17b461eb24e9cfcc9cd090a445f21b0d11981a8b5d46a59f5/nvmath_python-0.9.0-cp313-cp313-win_amd64.whl", hash = "sha256:ccc6d46ebae171362efe581e21c576bb34ef81853c53280b409ce231fb8e9791", size = 3460141, upload-time = "2026-04-22T02:24:00.457Z" }, + { url = "https://files.pythonhosted.org/packages/d1/2e/d7ae3fefb32de5aecdd0720c851fd7265d5185a101ff7ccbe7eca3d99913/nvmath_python-0.9.0-cp314-cp314-manylinux_2_28_aarch64.whl", hash = "sha256:84996657da62d21b95b0c31ddb246962eba6393157aec81eb6b1b1bce3f644f6", size = 4222704, upload-time = "2026-04-22T02:25:37.336Z" }, + { url = "https://files.pythonhosted.org/packages/ff/e0/305cfa433dfd3cb1fb4febf9e22d4b38a1c0aa3390ad585b69b67c03e609/nvmath_python-0.9.0-cp314-cp314-manylinux_2_28_x86_64.whl", hash = "sha256:e007a13a4823f04d4ce9601f9dcb44243803891b9b93f1e54d6f8c767540536c", size = 4469576, upload-time = "2026-04-22T02:29:25.986Z" }, + { url = "https://files.pythonhosted.org/packages/9a/c5/f3dedf7276b3d6d733d9b62e75c155e6772b1eb7a0eb7b6ae4176fb4b45d/nvmath_python-0.9.0-cp314-cp314-win_amd64.whl", hash = "sha256:e47407eb3696aab71fdd531856c30cd7e6710d4b8f1f00be5e01a875418d4101", size = 3558388, upload-time = "2026-04-22T02:23:36.894Z" }, ] [[package]] @@ -2698,11 +2663,11 @@ wheels = [ [[package]] name = "packaging" -version = "26.1" +version = "26.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/df/de/0d2b39fb4af88a0258f3bac87dfcbb48e73fbdea4a2ed0e2213f9a4c2f9a/packaging-26.1.tar.gz", hash = "sha256:f042152b681c4bfac5cae2742a55e103d27ab2ec0f3d88037136b6bfe7c9c5de", size = 215519, upload-time = "2026-04-14T21:12:49.362Z" } +sdist = { url = "https://files.pythonhosted.org/packages/d7/f1/e7a6dd94a8d4a5626c03e4e99c87f241ba9e350cd9e6d75123f992427270/packaging-26.2.tar.gz", hash = "sha256:ff452ff5a3e828ce110190feff1178bb1f2ea2281fa2075aadb987c2fb221661", size = 228134, upload-time = "2026-04-24T20:15:23.917Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/7a/c2/920ef838e2f0028c8262f16101ec09ebd5969864e5a64c4c05fad0617c56/packaging-26.1-py3-none-any.whl", hash = "sha256:5d9c0669c6285e491e0ced2eee587eaf67b670d94a19e94e3984a481aba6802f", size = 95831, upload-time = "2026-04-14T21:12:47.56Z" }, + { url = "https://files.pythonhosted.org/packages/df/b2/87e62e8c3e2f4b32e5fe99e0b86d576da1312593b39f47d8ceef365e95ed/packaging-26.2-py3-none-any.whl", hash = "sha256:5fc45236b9446107ff2415ce77c807cee2862cb6fac22b8a73826d0693b0980e", size = 100195, upload-time = "2026-04-24T20:15:22.081Z" }, ] [[package]] @@ -2725,11 +2690,11 @@ wheels = [ [[package]] name = "parso" -version = "0.8.6" +version = "0.8.7" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/81/76/a1e769043c0c0c9fe391b702539d594731a4362334cdf4dc25d0c09761e7/parso-0.8.6.tar.gz", hash = "sha256:2b9a0332696df97d454fa67b81618fd69c35a7b90327cbe6ba5c92d2c68a7bfd", size = 401621, upload-time = "2026-02-09T15:45:24.425Z" } +sdist = { url = "https://files.pythonhosted.org/packages/30/4b/90c937815137d43ce71ba043cd3566221e9df6b9c805f24b5d138c9d40a7/parso-0.8.7.tar.gz", hash = "sha256:eaaac4c9fdd5e9e8852dc778d2d7405897ec510f2a298071453e5e3a07914bb1", size = 401824, upload-time = "2026-05-01T23:13:02.138Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b6/61/fae042894f4296ec49e3f193aff5d7c18440da9e48102c3315e1bc4519a7/parso-0.8.6-py2.py3-none-any.whl", hash = "sha256:2c549f800b70a5c4952197248825584cb00f033b29c692671d3bf08bf380baff", size = 106894, upload-time = "2026-02-09T15:45:21.391Z" }, + { url = "https://files.pythonhosted.org/packages/99/5d/8268b644392ee874ee82a635cd0df1773de230bde356c38de28e298392cc/parso-0.8.7-py2.py3-none-any.whl", hash = "sha256:a8926eb2a1b915486941fdbd31e86a4baf88fe8c210f25f2f35ecec5b574ca1c", size = 107025, upload-time = "2026-05-01T23:12:58.867Z" }, ] [[package]] @@ -2750,11 +2715,11 @@ wheels = [ [[package]] name = "pathspec" -version = "1.0.4" +version = "1.1.1" 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" } +sdist = { url = "https://files.pythonhosted.org/packages/5a/82/42f767fc1c1143d6fd36efb827202a2d997a375e160a71eb2888a925aac1/pathspec-1.1.1.tar.gz", hash = "sha256:17db5ecd524104a120e173814c90367a96a98d07c45b2e10c2f3919fff91bf5a", size = 135180, upload-time = "2026-04-27T01:46:08.907Z" } 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" }, + { url = "https://files.pythonhosted.org/packages/f1/d9/7fb5aa316bc299258e68c73ba3bddbc499654a07f151cba08f6153988714/pathspec-1.1.1-py3-none-any.whl", hash = "sha256:a00ce642f577bf7f473932318056212bc4f8bfdf53128c78bbd5af0b9b20b189", size = 57328, upload-time = "2026-04-27T01:46:07.06Z" }, ] [[package]] @@ -2945,7 +2910,7 @@ provides-extras = ["test"] [[package]] name = "pecos-workspace" -version = "0.8.0.dev5" +version = "0.8.0.dev8" source = { virtual = "." } [package.optional-dependencies] @@ -2968,6 +2933,7 @@ dev = [ { name = "pre-commit" }, { name = "ruff" }, { name = "setuptools" }, + { name = "tomli", marker = "python_full_version < '3.11'" }, ] examples = [ { name = "jupyter" }, @@ -2986,7 +2952,6 @@ test = [ { name = "pytest-cov" }, { name = "pytest-timeout" }, { name = "stim" }, - { name = "wasmtime" }, ] [package.metadata] @@ -3006,6 +2971,7 @@ dev = [ { name = "pre-commit" }, { name = "ruff" }, { name = "setuptools", specifier = ">=82.0.1" }, + { name = "tomli", marker = "python_full_version < '3.11'", specifier = ">=1.1.0" }, ] examples = [ { name = "jupyter", specifier = ">=1.1.1" }, @@ -3022,7 +2988,6 @@ test = [ { name = "pytest-cov", specifier = "==7.1.0" }, { name = "pytest-timeout", specifier = "==2.4.0" }, { name = "stim", specifier = "==1.15.0" }, - { name = "wasmtime", specifier = "==43.0.0" }, ] [[package]] @@ -3180,35 +3145,35 @@ wheels = [ [[package]] name = "polars" -version = "1.39.3" +version = "1.40.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "polars-runtime-32" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/93/ab/f19e592fce9e000da49c96bf35e77cef67f9cb4b040bfa538a2764c0263e/polars-1.39.3.tar.gz", hash = "sha256:2e016c7f3e8d14fa777ef86fe0477cec6c67023a20ba4c94d6e8431eefe4a63c", size = 728987, upload-time = "2026-03-20T11:16:24.836Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b4/db/08f4ca10c5018813e7e0b59e4472302328b3d2ab1512f5a2157a814540e0/polars-1.39.3-py3-none-any.whl", hash = "sha256:c2b955ccc0a08a2bc9259785decf3d5c007b489b523bf2390cf21cec2bb82a56", size = 823985, upload-time = "2026-03-20T11:14:23.619Z" }, + { 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" }, ] [[package]] name = "polars-runtime-32" -version = "1.39.3" +version = "1.40.1" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/17/39/c8688696bc22b6c501e3b82ef3be10e543c07a785af5660f30997cd22dd2/polars_runtime_32-1.39.3.tar.gz", hash = "sha256:c728e4f469cafab501947585f36311b8fb222d3e934c6209e83791e0df20b29d", size = 2872335, upload-time = "2026-03-20T11:16:26.581Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/3b/74/1b41205f7368c9375ab1dea91178eaa20435fe3eff036390a53a7660b416/polars_runtime_32-1.39.3-cp310-abi3-macosx_10_12_x86_64.whl", hash = "sha256:425c0b220b573fa097b4042edff73114cc6d23432a21dfd2dc41adf329d7d2e9", size = 45273243, upload-time = "2026-03-20T11:14:26.691Z" }, - { url = "https://files.pythonhosted.org/packages/90/bf/297716b3095fe719be20fcf7af1d2b6ab069c38199bbace2469608a69b3a/polars_runtime_32-1.39.3-cp310-abi3-macosx_11_0_arm64.whl", hash = "sha256:ef5884711e3c617d7dc93519a7d038e242f5741cfe5fe9afd32d58845d86c562", size = 40842924, upload-time = "2026-03-20T11:14:31.154Z" }, - { url = "https://files.pythonhosted.org/packages/3d/3e/e65236d9d0d9babfa0ecba593413c06530fca60a8feb8f66243aa5dba92e/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:06b47f535eb1f97a9a1e5b0053ef50db3a4276e241178e37bbb1a38b1fa53b14", size = 43220650, upload-time = "2026-03-20T11:14:35.458Z" }, - { url = "https://files.pythonhosted.org/packages/b0/15/fc3e43f3fdf3f20b7dfb5abe871ab6162cf8fb4aeabf4cfad822d5dc4c79/polars_runtime_32-1.39.3-cp310-abi3-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:8bc9e13dc1d2e828331f2fe8ccbc9757554dc4933a8d3e85e906b988178f95ed", size = 46877498, upload-time = "2026-03-20T11:14:40.14Z" }, - { url = "https://files.pythonhosted.org/packages/3c/81/bd5f895919e32c6ab0a7786cd0c0ca961cb03152c47c3645808b54383f31/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_aarch64.whl", hash = "sha256:363d49e3a3e638fc943e2b9887940300a7d06789930855a178a4727949259dc2", size = 43380176, upload-time = "2026-03-20T11:14:45.566Z" }, - { url = "https://files.pythonhosted.org/packages/7a/3e/c86433c3b5ec0315bdfc7640d0c15d41f1216c0103a0eab9a9b5147d6c4c/polars_runtime_32-1.39.3-cp310-abi3-musllinux_1_2_x86_64.whl", hash = "sha256:7c206bdcc7bc62ea038d6adea8e44b02f0e675e0191a54c810703b4895208ea4", size = 46485933, upload-time = "2026-03-20T11:14:51.155Z" }, - { url = "https://files.pythonhosted.org/packages/54/ce/200b310cf91f98e652eb6ea09fdb3a9718aa0293ebf113dce325797c8572/polars_runtime_32-1.39.3-cp310-abi3-win_amd64.whl", hash = "sha256:d66ca522517554a883446957539c40dc7b75eb0c2220357fb28bc8940d305339", size = 46995458, upload-time = "2026-03-20T11:14:56.074Z" }, - { url = "https://files.pythonhosted.org/packages/da/76/2d48927e0aa2abbdde08cbf4a2536883b73277d47fbeca95e952de86df34/polars_runtime_32-1.39.3-cp310-abi3-win_arm64.whl", hash = "sha256:f49f51461de63f13e5dd4eb080421c8f23f856945f3f8bd5b2b1f59da52c2860", size = 41857648, upload-time = "2026-03-20T11:15:01.142Z" }, + { 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" }, ] [[package]] name = "pre-commit" -version = "4.5.1" +version = "4.6.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "cfgv" }, @@ -3217,9 +3182,9 @@ dependencies = [ { name = "pyyaml" }, { name = "virtualenv" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/40/f1/6d86a29246dfd2e9b6237f0b5823717f60cad94d47ddc26afa916d21f525/pre_commit-4.5.1.tar.gz", hash = "sha256:eb545fcff725875197837263e977ea257a402056661f09dae08e4b149b030a61", size = 198232, upload-time = "2025-12-16T21:14:33.552Z" } +sdist = { url = "https://files.pythonhosted.org/packages/8e/22/2de9408ac81acbb8a7d05d4cc064a152ccf33b3d480ebe0cd292153db239/pre_commit-4.6.0.tar.gz", hash = "sha256:718d2208cef53fdc38206e40524a6d4d9576d103eb16f0fec11c875e7716e9d9", size = 198525, upload-time = "2026-04-21T20:31:41.613Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/5d/19/fd3ef348460c80af7bb4669ea7926651d1f95c23ff2df18b9d24bab4f3fa/pre_commit-4.5.1-py2.py3-none-any.whl", hash = "sha256:3b3afd891e97337708c1674210f8eba659b52a38ea5f822ff142d10786221f77", size = 226437, upload-time = "2025-12-16T21:14:32.409Z" }, + { url = "https://files.pythonhosted.org/packages/80/6e/4b28b62ecb6aae56769c34a8ff1d661473ec1e9519e2d5f8b2c150086b26/pre_commit-4.6.0-py2.py3-none-any.whl", hash = "sha256:e2cf246f7299edcabcf15f9b0571fdce06058527f0a06535068a86d38089f29b", size = 226472, upload-time = "2026-04-21T20:31:40.092Z" }, ] [[package]] @@ -3300,7 +3265,7 @@ wheels = [ [[package]] name = "pydantic" -version = "2.13.2" +version = "2.13.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "annotated-types" }, @@ -3308,125 +3273,125 @@ dependencies = [ { name = "typing-extensions" }, { name = "typing-inspection" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/09/e5/06d23afac9973109d1e3c8ad38e1547a12e860610e327c05ee686827dc37/pydantic-2.13.2.tar.gz", hash = "sha256:b418196607e61081c3226dcd4f0672f2a194828abb9109e9cfb84026564df2d1", size = 843836, upload-time = "2026-04-17T09:31:59.636Z" } +sdist = { url = "https://files.pythonhosted.org/packages/18/a5/b60d21ac674192f8ab0ba4e9fd860690f9b4a6e51ca5df118733b487d8d6/pydantic-2.13.4.tar.gz", hash = "sha256:c40756b57adaa8b1efeeced5c196f3f3b7c435f90e84ea7f443901bec8099ef6", size = 844775, upload-time = "2026-05-06T13:43:05.343Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/77/ca/b45c378e6e8d0b90577288b533e04e95b7afd61bb1d51b6c263176435489/pydantic-2.13.2-py3-none-any.whl", hash = "sha256:a525087f4c03d7e7456a3de89b64cd693d2229933bb1068b9af6befd5563694e", size = 471947, upload-time = "2026-04-17T09:31:57.541Z" }, + { url = "https://files.pythonhosted.org/packages/fd/7b/122376b1fd3c62c1ed9dc80c931ace4844b3c55407b6fb2d199377c9736f/pydantic-2.13.4-py3-none-any.whl", hash = "sha256:45a282cde31d808236fd7ea9d919b128653c8b38b393d1c4ab335c62924d9aba", size = 472262, upload-time = "2026-05-06T13:43:02.641Z" }, ] [[package]] name = "pydantic-core" -version = "2.46.2" +version = "2.46.4" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "typing-extensions" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/bb/4742f05b739b2478459bb16fa8470549518c802e06ddcf3f106c5081315e/pydantic_core-2.46.2.tar.gz", hash = "sha256:37bb079f9ee3f1a519392b73fda2a96379b31f2013c6b467fe693e7f2987f596", size = 471269, upload-time = "2026-04-17T09:10:07.017Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/a4/f2/98f37e836c5ba0335432768e0d8645e6f50a3c838b48a74d9256256784fc/pydantic_core-2.46.2-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:160ef93541f4f84e3e5068e6c1f64d8fd6f57586e5853d609b467d3333f8146a", size = 2108178, upload-time = "2026-04-17T09:10:24.689Z" }, - { url = "https://files.pythonhosted.org/packages/55/69/975458de8e5453322cfc57d6c7029c3e66d9e7a4389c53ddd5ad02d5e5da/pydantic_core-2.46.2-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:1a9124b63f4f40a12a0666df57450b4c24b98407ff74349221b869ec085a5d8e", size = 1949232, upload-time = "2026-04-17T09:11:39.536Z" }, - { url = "https://files.pythonhosted.org/packages/94/8d/938175e6e82d051ac4644765680db06571d7e106a42f760da09bd90f6525/pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:de12004a7da7f1eb67ece37439a5a23a915636085dd042176fda362e006e6940", size = 1974741, upload-time = "2026-04-17T09:13:01.922Z" }, - { url = "https://files.pythonhosted.org/packages/f2/38/7329f8ac5c732bddf15f939c2add40b95170e0ecca5ef124c12def3f78ba/pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:a070c7769fec277409ad0b3d55b2f0a3703a6f00cf5031fe93090f155bf56382", size = 2041905, upload-time = "2026-04-17T09:11:11.94Z" }, - { url = "https://files.pythonhosted.org/packages/99/2c/47cfd069937ee5cbc0d9e18fa9795c8f80c49a6b4fc777d4cd870f2ade7b/pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:41d701bb34f81f0b11c724cc544b9a10b26a28f4d0d1197f2037c91225708706", size = 2222703, upload-time = "2026-04-17T09:10:31.196Z" }, - { url = "https://files.pythonhosted.org/packages/83/b0/7ed83ca8cd92c99bcab90cf42ed953723fbc19d8a20c8c12bb68c51febc1/pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:19631e7350b7a574fb6b6db222f4b17e8bd31803074b3307d07df62379d2b2e4", size = 2276317, upload-time = "2026-04-17T09:09:53.263Z" }, - { url = "https://files.pythonhosted.org/packages/85/70/50b1b62990996e7916aae2852b29cbf3ecc3fdae78209eb284cd61e2c918/pydantic_core-2.46.2-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48b1059e4f2a6ec3e41983148eb1eec5ef9fa3a80bbc4ac0893ac76b115fe039", size = 2092152, upload-time = "2026-04-17T09:10:44.683Z" }, - { url = "https://files.pythonhosted.org/packages/c1/51/a062864e6b34ada7e343ad9ed29368e495620a8ef1c009b47a68b46e1634/pydantic_core-2.46.2-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:df73724fce8ad53c670358c905b37930bd7b9d92e57db640a65c53b2706eee00", size = 2118091, upload-time = "2026-04-17T09:10:05.083Z" }, - { url = "https://files.pythonhosted.org/packages/07/e0/fcc97c4d0319615dc0b5b132b420904639652f8514e9c76482acb70ea1d4/pydantic_core-2.46.2-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:a0891a9be0def16fb320af21a198ece052eed72bf44d73d8ff43f702bd26fd6b", size = 2174304, upload-time = "2026-04-17T09:11:00.54Z" }, - { url = "https://files.pythonhosted.org/packages/00/52/28f53796ca74b7e3dd45938f300517f04970e985ad600d0d0f36a11378bd/pydantic_core-2.46.2-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:2ca790779aa1cba1329b8dc42ccebada441d9ac1d932de980183d544682c646d", size = 2181444, upload-time = "2026-04-17T09:11:45.442Z" }, - { url = "https://files.pythonhosted.org/packages/22/49/164d5d3a7356d2607a72e77264a3b252a7c7d9362a81fc9df47bef7ae3aa/pydantic_core-2.46.2-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:6b865eb702c3af71cf7331919a787563ce2413f7a54ef49ec6709a01b4f22ce6", size = 2328611, upload-time = "2026-04-17T09:10:08.574Z" }, - { url = "https://files.pythonhosted.org/packages/6b/77/6266bb3b79c27b533e5ee02c1e3da5848872112178880cc5006a84e857ac/pydantic_core-2.46.2-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:631bec5f951a30a4b332b4a57d0cdd5a2c8187eb71301f966425f2e54a697855", size = 2351070, upload-time = "2026-04-17T09:13:34.92Z" }, - { url = "https://files.pythonhosted.org/packages/10/7f/d4233852d16d8e85b034a524d8017e051a0aa4acd04c64c3a69a1a2a0ba6/pydantic_core-2.46.2-cp310-cp310-win32.whl", hash = "sha256:8cbd9d67357f3a925f2af1d44db3e8ef1ce1a293ea0add98081b072d4a12e3b4", size = 1976750, upload-time = "2026-04-17T09:13:15.537Z" }, - { url = "https://files.pythonhosted.org/packages/70/31/d65117cf5f89d81705da5b1dcdad8efa0a0b65dbbc7f13cafbabb7d01615/pydantic_core-2.46.2-cp310-cp310-win_amd64.whl", hash = "sha256:dd51dd16182b4bfdcefd27b39b856aa4a57b77f15b231a2d10c45391b0a02028", size = 2073989, upload-time = "2026-04-17T09:12:17.315Z" }, - { url = "https://files.pythonhosted.org/packages/89/91/089f517a725f29084364169437833ab0ae4da4d7a6ed9d4474db7f1412e6/pydantic_core-2.46.2-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:d8060f42db3cd204871db0afd51fef54a13fa544c4dd48cdcae2e174ef40c8ba", size = 2106218, upload-time = "2026-04-17T09:10:48.023Z" }, - { url = "https://files.pythonhosted.org/packages/a0/92/23858ed1b58f2a134e50c2fdd0e34ea72721ccb257e1e9346514e1ccb5b9/pydantic_core-2.46.2-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:73a9d2809bd8d4a7cda4d336dc996a565eb4feaaa39932f9d85a65fa18382f28", size = 1948087, upload-time = "2026-04-17T09:11:58.639Z" }, - { url = "https://files.pythonhosted.org/packages/5d/ac/e2240fccb4794e965817593d5a46cf5ea22f2001b73fe360b7578925b7d8/pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b0a2dee92dfaabcfb93629188c3e9cf74fdfc0f22e7c369cb444a98814a1e50", size = 1972931, upload-time = "2026-04-17T09:13:13.304Z" }, - { url = "https://files.pythonhosted.org/packages/1a/da/3b11dab2aa15c5c8ed20a01eb7aa432a78b8e3a4713659f7e58490a020a5/pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3098446ba8cf774f61cb8d4008c1dba14a30426a15169cd95ac3392a461193b1", size = 2040454, upload-time = "2026-04-17T09:13:47.895Z" }, - { url = "https://files.pythonhosted.org/packages/d7/39/c4cf5e1f1c6c34c53c0902039c95d81dc15cdd1f03634bd1a93f33e70a72/pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:57c584af6c375ea3f826d8131a94cb212b3d9926eaff67117e3711bbff3a83a5", size = 2221320, upload-time = "2026-04-17T09:13:08.568Z" }, - { url = "https://files.pythonhosted.org/packages/c7/46/891035bc9e93538e754c3188424d24b5a69ec3ae5210fa01d483e99b3302/pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:547381cca999be88b4715a0ed7afa11f07fc7e53cb1883687b190d25a92c56cf", size = 2274559, upload-time = "2026-04-17T09:11:10.257Z" }, - { url = "https://files.pythonhosted.org/packages/ab/d0/7af0b905b3148152c159c9caf203e7ecd9b90b76389f0862e6ab0cf1b2a3/pydantic_core-2.46.2-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:caeed15dcb1233a5a94bc6ff37ef5393cf5b33a45e4bdfb2d6042f3d24e1cb27", size = 2089239, upload-time = "2026-04-17T09:13:06.326Z" }, - { url = "https://files.pythonhosted.org/packages/c5/bc/566afe02ba2de37712eece74ac7bfba322abd7916410bf90504f1b17ddad/pydantic_core-2.46.2-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:c05f53362568c75476b5c96659377a5dfd982cfbe5a5c07de5106d08a04efc4f", size = 2116182, upload-time = "2026-04-17T09:11:33.738Z" }, - { url = "https://files.pythonhosted.org/packages/4e/5b/3fcb3a229bbfa23b0e3c65014057af0f9d51ec7a2d9f7adb282f41ff5ac8/pydantic_core-2.46.2-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2643ac7eae296200dbd48762a1c852cf2cad5f5e3eba34e652053cebf03becf8", size = 2172346, upload-time = "2026-04-17T09:10:46.472Z" }, - { url = "https://files.pythonhosted.org/packages/43/9a/baa9e3aa70ea7bbcb9db0f87162a371649ac80c03e43eb54af193390cf17/pydantic_core-2.46.2-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:dc4620a47c6fe6a39f89392c00833a82fc050ce90169798f78a25a8d4df03b6e", size = 2179540, upload-time = "2026-04-17T09:11:21.881Z" }, - { url = "https://files.pythonhosted.org/packages/bd/46/912047a5427f949c909495704b3c8b9ead9d1c66f87e96606011beab1fcb/pydantic_core-2.46.2-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:78cb0d2453b50bf2035f85fd0d9cfabdb98c47f9c53ddb7c23873cd83da9560b", size = 2327423, upload-time = "2026-04-17T09:13:40.291Z" }, - { url = "https://files.pythonhosted.org/packages/e9/bf/c5e661451dc9411c2ab88a244c1ba57644950c971486040dc200f77b69f4/pydantic_core-2.46.2-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:f0c1cbb7d6112932cc188c6be007a5e2867005a069e47f42fe67bf5f122b0908", size = 2348652, upload-time = "2026-04-17T09:10:37.76Z" }, - { url = "https://files.pythonhosted.org/packages/77/b3/3219e7c522af54b010cf7422dcb11cc6616a4414d1ccd628b0d3f61c6af6/pydantic_core-2.46.2-cp311-cp311-win32.whl", hash = "sha256:c1ce5b2366f85cfdbf7f0907755043707f86d09a5b1b1acebbb7bf1600d75c64", size = 1974410, upload-time = "2026-04-17T09:13:27.392Z" }, - { url = "https://files.pythonhosted.org/packages/e5/29/e5cfac8a74c59873dfd47d3a1477c39ad9247639a7120d3e251a9ff12417/pydantic_core-2.46.2-cp311-cp311-win_amd64.whl", hash = "sha256:f1a6197eadff5bd0bb932f12bb038d403cb75db5b0b391e70e816a647745ddaf", size = 2071158, upload-time = "2026-04-17T09:09:57.69Z" }, - { url = "https://files.pythonhosted.org/packages/6f/8b/b7b19b717cdb3675cb109de143f62d4dc62f5d4a0b9879b6f1ace62c6654/pydantic_core-2.46.2-cp311-cp311-win_arm64.whl", hash = "sha256:15e42885b283f87846ee79e161002c5c496ef747a73f6e47054f45a13d9035bc", size = 2043507, upload-time = "2026-04-17T09:09:51.828Z" }, - { url = "https://files.pythonhosted.org/packages/97/ec/2fafa4c86f5d2a69372c7cddef30925fd0e370b1efaf556609c1a0196d8a/pydantic_core-2.46.2-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:ea1ad8c89da31512fe2d249cf0638fb666925bda341901541bc5f3311c6fcc9e", size = 2101729, upload-time = "2026-04-17T09:12:30.042Z" }, - { url = "https://files.pythonhosted.org/packages/cf/55/be5386c2c4b49af346e8a26b748194ff25757bbb6cf544130854e997af7a/pydantic_core-2.46.2-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:b308da17b92481e0587244631c5529e5d91d04cb2b08194825627b1eca28e21e", size = 1951546, upload-time = "2026-04-17T09:10:10.585Z" }, - { url = "https://files.pythonhosted.org/packages/29/92/89e273a055ce440e6636c756379af35ad86da9d336a560049c3ba5e41c80/pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d333a50bdd814a917d8d6a7ee35ba2395d53ddaa882613bc24e54a9d8b129095", size = 1976178, upload-time = "2026-04-17T09:11:49.619Z" }, - { url = "https://files.pythonhosted.org/packages/91/b3/e4664469cf70c0cb0f7b2f5719d64e5968bb6f38217042c2afa3d3c4ba17/pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:1d00b99590c5bd1fabbc5d28b170923e32c1b1071b1f1de1851a4d14d89eb192", size = 2051697, upload-time = "2026-04-17T09:12:04.917Z" }, - { url = "https://files.pythonhosted.org/packages/98/58/dbf68213ee06ce51cdd6d8c95f97980e646858c45bd96bd2dfb40433be73/pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9f0e686960ffe9e65066395af856ac2d52c159043144433602c50c221d81c1ba", size = 2233160, upload-time = "2026-04-17T09:12:00.956Z" }, - { url = "https://files.pythonhosted.org/packages/f5/d3/68092aa0ee6c60ff4de4740eb82db3d4ce338ec89b3cecb978c532472f12/pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2d1128da41c9cb474e0a4701f9c363ec645c9d1a02229904c76bf4e0a194fde2", size = 2298398, upload-time = "2026-04-17T09:10:29.694Z" }, - { url = "https://files.pythonhosted.org/packages/e4/51/5d6155eb737db55b0ad354ca5f333ef009f75feb67df2d79a84bace45af6/pydantic_core-2.46.2-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:48649cf2d8c358d79586e9fb2f8235902fcaa2d969ec1c5301f2d1873b2f8321", size = 2094058, upload-time = "2026-04-17T09:12:10.995Z" }, - { url = "https://files.pythonhosted.org/packages/6b/f3/eb4a986197d71319430464ff181226c95adc8f06d932189b158bae5a82f5/pydantic_core-2.46.2-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:b902f0fc7c2cf503865a05718b68147c6cd5d0a3867af38c527be574a9fa6e9d", size = 2130388, upload-time = "2026-04-17T09:12:41.159Z" }, - { url = "https://files.pythonhosted.org/packages/56/00/44a9c4fe6d0f64b5786d6a8c649d6f0e34ba6c89b3663add1066e54451a2/pydantic_core-2.46.2-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:e80011f808b03d1d87a8f1e76ae3da19a18eb706c823e17981dcf1fae43744fc", size = 2184245, upload-time = "2026-04-17T09:12:36.532Z" }, - { url = "https://files.pythonhosted.org/packages/78/6b/685b98a834d5e3d1c34a1bde1627525559dd223b75075bc7490cdb24eb33/pydantic_core-2.46.2-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b839d5c802e31348b949b6473f8190cddbf7d47475856d8ac995a373ee16ec59", size = 2186842, upload-time = "2026-04-17T09:13:04.054Z" }, - { url = "https://files.pythonhosted.org/packages/22/64/caa2f5a2ac8b6113adaa410ccdf31ba7f54897a6e54cd0d726fc7e780c88/pydantic_core-2.46.2-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:c6b1064f3f9cf9072e1d59dd2936f9f3b668bec1c37039708c9222db703c0d5b", size = 2336066, upload-time = "2026-04-17T09:12:13.006Z" }, - { url = "https://files.pythonhosted.org/packages/ee/f9/7d2701bf82945b5b9e7df8347be97ef6a36da2846bfe5b4afec299ffe27b/pydantic_core-2.46.2-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:37a68e6f2ac95578ce3c0564802404b27b24988649616e556c07e77111ed3f1d", size = 2363691, upload-time = "2026-04-17T09:13:42.972Z" }, - { url = "https://files.pythonhosted.org/packages/3b/65/0dab11574101522941055109419db3cc09db871643dc3fc74e2413215e5b/pydantic_core-2.46.2-cp312-cp312-win32.whl", hash = "sha256:d9ffa75a7ef4b97d6e5e205fabd4304ef01fec09e6f1bdde04b9ad1b07d20289", size = 1958801, upload-time = "2026-04-17T09:11:31.981Z" }, - { url = "https://files.pythonhosted.org/packages/13/2b/df84baa609c676f6450b8ecad44ea59146c805e3371b7b52443c0899f989/pydantic_core-2.46.2-cp312-cp312-win_amd64.whl", hash = "sha256:0551f2d2ddb68af5a00e26497f8025c538f73ef3cb698f8e5a487042cd2792a8", size = 2072634, upload-time = "2026-04-17T09:11:02.407Z" }, - { url = "https://files.pythonhosted.org/packages/d1/4e/e1ce8029fc438086a946739bf9d596f70ff470aad4a8345555920618cabe/pydantic_core-2.46.2-cp312-cp312-win_arm64.whl", hash = "sha256:83aef30f106edcc21a6a4cc44b82d3169a1dbe255508db788e778f3c804d3583", size = 2026188, upload-time = "2026-04-17T09:13:11.083Z" }, - { url = "https://files.pythonhosted.org/packages/07/2b/662e48254479a2d3450ba24b1e25061108b64339794232f503990c519144/pydantic_core-2.46.2-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:d26e9eea3715008a09a74585fe9becd0c67fbb145dc4df9756d597d7230a652c", size = 2101762, upload-time = "2026-04-17T09:10:13.87Z" }, - { url = "https://files.pythonhosted.org/packages/73/ab/bafd7c7503757ccc8ec4d1911e106fe474c629443648c51a88f08b0fe91a/pydantic_core-2.46.2-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:48b36e3235140510dc7861f0cd58b714b1cdd3d48f75e10ce52e69866b746f10", size = 1951814, upload-time = "2026-04-17T09:12:25.934Z" }, - { url = "https://files.pythonhosted.org/packages/92/cc/7549c2d57ba2e9a42caa5861a2d398dbe31c02c6aca783253ace59ce84f8/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:36b1f99dc451f1a3981f236151465bcf995bbe712d0727c9f7b236fe228a8133", size = 1977329, upload-time = "2026-04-17T09:13:37.605Z" }, - { url = "https://files.pythonhosted.org/packages/18/50/7ed4a8a0d478a4dca8f0134a5efa7193f03cc8520dd4c9509339fb2e5002/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:8641c8d535c2d95b45c2e19b646ecd23ebba35d461e0ae48a3498277006250ab", size = 2051832, upload-time = "2026-04-17T09:12:49.771Z" }, - { url = "https://files.pythonhosted.org/packages/dc/16/bb35b193741c0298ddc5f5e4234269efdc0c65e2bcd198aa0de9b68845e4/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:20fb194788a0a50993e87013e693494ba183a2af5b44e99cf060bbae10912b11", size = 2233127, upload-time = "2026-04-17T09:11:04.449Z" }, - { url = "https://files.pythonhosted.org/packages/91/a5/98f4b637149185addea19e1785ea20c373cca31b202f589111d8209d9873/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9262d11d0cd11ee3303a95156939402bed6cedfe5ed0e331b95a283a4da6eb8b", size = 2297418, upload-time = "2026-04-17T09:11:25.929Z" }, - { url = "https://files.pythonhosted.org/packages/36/90/93a5d21990b152da7b7507b7fddb0b935f6a0984d57ac3ec45a6e17777a2/pydantic_core-2.46.2-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:ac204542736aa295fa25f713b7fad6fc50b46ab7764d16087575c85f085174f3", size = 2093735, upload-time = "2026-04-17T09:12:06.908Z" }, - { url = "https://files.pythonhosted.org/packages/14/22/b8b1ffdddf08b4e84380bcb67f41dbbf4c171377c1d36fc6290794bb2094/pydantic_core-2.46.2-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:9a7c43a0584742dface3ca0daf6f719d46c1ac2f87cf080050f9ae052c75e1b2", size = 2127570, upload-time = "2026-04-17T09:11:53.906Z" }, - { url = "https://files.pythonhosted.org/packages/c6/26/e60d72b4e2d0ce1fa811044a974412ac1c567fe067d97b3e6b290530786e/pydantic_core-2.46.2-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fd05e1edb6a90ad446fa268ab09e59202766b837597b714b2492db11ee87fab9", size = 2183524, upload-time = "2026-04-17T09:11:30.092Z" }, - { url = "https://files.pythonhosted.org/packages/35/32/36bec7584a1eefb17dec4dfa1c946d3fe4440f466c5705b8adfda69c9a9f/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:91155b110788b5501abc7ea954f1d08606219e4e28e3c73a94124307c06efb80", size = 2185408, upload-time = "2026-04-17T09:10:57.228Z" }, - { url = "https://files.pythonhosted.org/packages/fc/d6/1a5689d873620efd67d6b163db0c444c056adb0849b5bc33e2b9f09665a6/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:e4e2c72a529fa03ff228be1d2b76944013f428220b764e03cc50ada67e17a42c", size = 2335171, upload-time = "2026-04-17T09:11:43.369Z" }, - { url = "https://files.pythonhosted.org/packages/3e/8e/675104802abe8ef502b072050ee5f2e915251aa1a3af87e1015ce31ec42d/pydantic_core-2.46.2-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:56291ec1a11c3499890c99a8fd9053b47e60fe837a77ec72c0671b1b8b3dce24", size = 2362743, upload-time = "2026-04-17T09:10:18.333Z" }, - { url = "https://files.pythonhosted.org/packages/8d/bc/86c5dde4fa6e24467680eef5047da3c1a19be0a527d0d8e14aa76b39307c/pydantic_core-2.46.2-cp313-cp313-win32.whl", hash = "sha256:b50f9c5f826ddca1246f055148df939f5f3f2d0d96db73de28e2233f22210d4c", size = 1958074, upload-time = "2026-04-17T09:12:38.622Z" }, - { url = "https://files.pythonhosted.org/packages/2a/97/2537e8c1282b2c4eb062580c0d7a4339e10b072b803d1ee0b7f1f0a5c22c/pydantic_core-2.46.2-cp313-cp313-win_amd64.whl", hash = "sha256:251a57788823230ca8cbc99e6245d1a2ed6e180ec4864f251c94182c580c7f2e", size = 2071741, upload-time = "2026-04-17T09:13:32.405Z" }, - { url = "https://files.pythonhosted.org/packages/da/aa/2ee75798706f9dbc4e76dbe59e41a396c5c311e3d6223b9cf6a5fa7780be/pydantic_core-2.46.2-cp313-cp313-win_arm64.whl", hash = "sha256:315d32d1a71494d6b4e1e14a9fa7a4329597b4c4340088ad7e1a9dafbeed92a9", size = 2025955, upload-time = "2026-04-17T09:10:15.567Z" }, - { url = "https://files.pythonhosted.org/packages/d0/96/a50ccb6b539ae780f73cea74905468777680e30c6c3bdf714b9d4c116ea0/pydantic_core-2.46.2-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:4f59b45f3ef8650c0c736a57f59031d47ed9df4c0a64e83796849d7d14863a2d", size = 2097111, upload-time = "2026-04-17T09:10:49.617Z" }, - { url = "https://files.pythonhosted.org/packages/34/5f/fdead7b3afa822ab6e5a18ee0ecffd54937de1877c01ed13a342e0fb3f07/pydantic_core-2.46.2-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:3a075a29ebef752784a91532a1a85be6b234ccffec0a9d7978a92696387c3da6", size = 1951904, upload-time = "2026-04-17T09:12:32.062Z" }, - { url = "https://files.pythonhosted.org/packages/95/e0/1c5d547e550cdab1bec737492aa08865337af6fe7fc9b96f7f45f17d9519/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:0d12d786e30c04a9d307c5d7080bf720d9bac7f1668191d8e37633a9562749e2", size = 1978667, upload-time = "2026-04-17T09:11:35.589Z" }, - { url = "https://files.pythonhosted.org/packages/0e/cb/665ce629e218c8228302cb94beff4f6531082a2c87d3ecc3d5e63a26f392/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:0d5e6d6343b0b5dcacb3503b5de90022968da8ed0ab9ab39d3eda71c20cbf84e", size = 2046721, upload-time = "2026-04-17T09:11:47.725Z" }, - { url = "https://files.pythonhosted.org/packages/77/e9/6cb2cf60f54c1472bbdfce19d957553b43dbba79d1d7b2930a195c594785/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:233eebac0999b6b9ba76eb56f3ec8fce13164aa16b6d2225a36a79e0f95b5973", size = 2228483, upload-time = "2026-04-17T09:12:08.837Z" }, - { url = "https://files.pythonhosted.org/packages/0d/2a/93e018dd5571f781ebaeda8c0cf65398489d5bee9b1f484df0b6149b43b9/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9cc0eee720dd2f14f3b7c349469402b99ad81a174ab49d3533974529e9d93992", size = 2294663, upload-time = "2026-04-17T09:12:52.053Z" }, - { url = "https://files.pythonhosted.org/packages/5e/4f/49e57ca55c770c93d9bb046666a54949b42e3c9099a0c5fe94557873fe30/pydantic_core-2.46.2-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:83ee76bf2c9910513dbc19e7d82367131fa7508dedd6186a462393071cc11059", size = 2098742, upload-time = "2026-04-17T09:13:45.472Z" }, - { url = "https://files.pythonhosted.org/packages/c6/b0/6e46b5cd3332af665f794b8cdeea206618a8630bd9e7bcc36864518fce81/pydantic_core-2.46.2-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:d61db38eb4ee5192f0c261b7f2d38e420b554df8912245e3546aee5c45e2fd78", size = 2125922, upload-time = "2026-04-17T09:12:54.304Z" }, - { url = "https://files.pythonhosted.org/packages/06/d1/40850c81585be443a2abfdf7f795f8fae831baf8e2f9b2133c8246ac671c/pydantic_core-2.46.2-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:8f09a713d17bcd55da8ab02ebd9110c5246a49c44182af213b5212800af8bc83", size = 2183000, upload-time = "2026-04-17T09:10:59.027Z" }, - { url = "https://files.pythonhosted.org/packages/04/af/8493d7dfa03ebb7866909e577c6aa65ea0de7377b86023cc51d0c8e11db3/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:30cacc5fb696e64b8ef6fd31d9549d394dd7d52760db072eecb98e37e3af1677", size = 2180335, upload-time = "2026-04-17T09:12:57.01Z" }, - { url = "https://files.pythonhosted.org/packages/72/5b/1f6a344c4ffdf284da41c6067b82d5ebcbd11ce1b515ae4b662d4adb6f61/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:7ccfb105fcfe91a22bbb5563ad3dc124bc1aa75bfd2e53a780ab05f78cdf6108", size = 2330002, upload-time = "2026-04-17T09:12:02.958Z" }, - { url = "https://files.pythonhosted.org/packages/25/ff/9a694126c12d6d2f48a0cafa6f8eef88ef0d8825600e18d03ff2e896c3b2/pydantic_core-2.46.2-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:13ffef637dc8370c249e5b26bd18e9a80a4fca3d809618c44e18ec834a7ca7a8", size = 2359920, upload-time = "2026-04-17T09:10:27.764Z" }, - { url = "https://files.pythonhosted.org/packages/51/c8/3a35c763d68a9cb2675eb10ef242cf66c5d4701b28ae12e688d67d2c180e/pydantic_core-2.46.2-cp314-cp314-win32.whl", hash = "sha256:1b0ab6d756ca2704a938e6c31b53f290c2f9c10d3914235410302a149de1a83e", size = 1953701, upload-time = "2026-04-17T09:13:30.021Z" }, - { url = "https://files.pythonhosted.org/packages/1a/6a/f2726a780365f7dfd89d62036f984f7acb99978c60c5e1fa7c0cb898ed11/pydantic_core-2.46.2-cp314-cp314-win_amd64.whl", hash = "sha256:99ebade8c9ada4df975372d8dd25883daa0e379a05f1cd0c99aa0c04368d01a6", size = 2071867, upload-time = "2026-04-17T09:10:39.205Z" }, - { url = "https://files.pythonhosted.org/packages/e1/79/76baacb9feba3d7c399b245ca1a29c74ea0db04ea693811374827eec2290/pydantic_core-2.46.2-cp314-cp314-win_arm64.whl", hash = "sha256:de87422197cf7f83db91d89c86a21660d749b3cd76cd8a45d115b8e675670f02", size = 2017252, upload-time = "2026-04-17T09:10:26.175Z" }, - { url = "https://files.pythonhosted.org/packages/f1/3b/77c26938f817668d9ad9bab1a905cb23f11d9a3d4bf724d429b3e55a8eaf/pydantic_core-2.46.2-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:236f22b4a206b5b61db955396b7cf9e2e1ff77f372efe9570128ccfcd6a525eb", size = 2094545, upload-time = "2026-04-17T09:12:19.339Z" }, - { url = "https://files.pythonhosted.org/packages/fe/de/42c13f590e3c260966aa49bcdb1674774f975467c49abd51191e502bea28/pydantic_core-2.46.2-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:c2012f64d2cd7cca50f49f22445aa5a88691ac2b4498ee0a9a977f8ca4f7289f", size = 1933953, upload-time = "2026-04-17T09:09:55.889Z" }, - { url = "https://files.pythonhosted.org/packages/4e/84/ebe3ebb3e2d8db656937cfa6f97f544cb7132f2307a4a7dfdcd0ea102a12/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:d07d6c63106d3a9c9a333e2636f9c82c703b1a9e3b079299e58747964e4fdb72", size = 1974435, upload-time = "2026-04-17T09:10:12.371Z" }, - { url = "https://files.pythonhosted.org/packages/b9/15/0bf51ca6709477cd4ef86148b6d7844f3308f029eac361dd0383f1e17b1a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c326a2b4b85e959d9a1fc3a11f32f84611b6ec07c053e1828a860edf8d068208", size = 2031113, upload-time = "2026-04-17T09:10:00.752Z" }, - { url = "https://files.pythonhosted.org/packages/02/ae/b7b5af9b79db036d9e61a44c481c17a213dc8fc4b8b71fe6875a72fc778b/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:ac8a65e798f2462552c00d2e013d532c94d646729dda98458beaf51f9ec7b120", size = 2236325, upload-time = "2026-04-17T09:10:33.227Z" }, - { url = "https://files.pythonhosted.org/packages/a6/ae/ecef7477b5a03d4a499708f7e75d2836452ebb70b776c2d64612b334f57a/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:5a3c2bc1cc8164bedbc160b7bb1e8cc1e8b9c27f69ae4f9ae2b976cdae02b2dd", size = 2278135, upload-time = "2026-04-17T09:10:23.287Z" }, - { url = "https://files.pythonhosted.org/packages/db/e4/2f9d82faa47af6c39fc3f120145fd915971e1e0cb6b55b494fad9fdf8275/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:e69aa5e10b7e8b1bb4a6888650fd12fcbf11d396ca11d4a44de1450875702830", size = 2109071, upload-time = "2026-04-17T09:11:06.149Z" }, - { url = "https://files.pythonhosted.org/packages/f1/9c/677cf10873fbd0b116575ab7b97c90482b21564f8a8040beb18edef7a577/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:4e6df5c3301e65fb42bc5338bf9a1027a02b0a31dc7f54c33775229af474daf0", size = 2106028, upload-time = "2026-04-17T09:10:51.525Z" }, - { url = "https://files.pythonhosted.org/packages/d6/53/6a06183544daba51c059123a2064a99039df25f115a06bdb26f2ea177038/pydantic_core-2.46.2-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:2c2f6e32548ac8d559b47944effcf8ae4d81c161f6b6c885edc53bc08b8f192d", size = 2164816, upload-time = "2026-04-17T09:11:56.187Z" }, - { url = "https://files.pythonhosted.org/packages/57/6f/10fcdd9e3eca66fc828eef0f6f5850f2dd3bca2c59e6e041fb8bc3da39be/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:b089a81c58e6ea0485562bbbbbca4f65c0549521606d5ef27fba217aac9b665a", size = 2166130, upload-time = "2026-04-17T09:10:03.804Z" }, - { url = "https://files.pythonhosted.org/packages/29/83/92d3fd0e0156cad2e3cb5c26de73794af78ac9fa0c22ab666e566dd67061/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:7f700a6d6f64112ae9193709b84303bbab84424ad4b47d0253301aabce9dfc70", size = 2316605, upload-time = "2026-04-17T09:12:45.249Z" }, - { url = "https://files.pythonhosted.org/packages/97/f1/facffdb970981068219582e499b8d0871ed163ffcc6b347de5c412669e4c/pydantic_core-2.46.2-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:67db6814beaa5fefe91101ec7eb9efda613795767be96f7cf58b1ca8c9ca9972", size = 2358385, upload-time = "2026-04-17T09:09:54.657Z" }, - { url = "https://files.pythonhosted.org/packages/8b/a1/b8160b2f22b2199467bc68581a4ed380643c16b348a27d6165c6c242d694/pydantic_core-2.46.2-cp314-cp314t-win32.whl", hash = "sha256:32fbc7447be8e3be99bf7869f7066308f16be55b61f9882c2cefc7931f5c7664", size = 1942373, upload-time = "2026-04-17T09:12:59.594Z" }, - { url = "https://files.pythonhosted.org/packages/0d/90/db89acabe5b150e11d1b59fe3d947dda2ef6abbfef5c82f056ff63802f5d/pydantic_core-2.46.2-cp314-cp314t-win_amd64.whl", hash = "sha256:b317a2b97019c0b95ce99f4f901ae383f40132da6706cdf1731066a73394c25c", size = 2052078, upload-time = "2026-04-17T09:10:19.96Z" }, - { url = "https://files.pythonhosted.org/packages/97/32/e19b83ceb07a3f1bb21798407790bbc9a31740158fd132b94139cb84e16c/pydantic_core-2.46.2-cp314-cp314t-win_arm64.whl", hash = "sha256:7dcb9d40930dfad7ab6b20bcc6ca9d2b030b0f347a0cd9909b54bd53ead521b1", size = 2016941, upload-time = "2026-04-17T09:12:34.447Z" }, - { url = "https://files.pythonhosted.org/packages/25/ec/e91aa08df1c33d5e3c2b60c07a1eca9f21809728a824c7b467bb3bda68b5/pydantic_core-2.46.2-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:7c5a5b3dbb9e8918e223be6580da5ffcf861c0505bbc196ebed7176ce05b7b4e", size = 2105046, upload-time = "2026-04-17T09:10:55.614Z" }, - { url = "https://files.pythonhosted.org/packages/f0/73/27112400a0452e375290e7c40aef5cc9844ac0920fb1029238cfc68121fa/pydantic_core-2.46.2-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:bc1e8ce33d5a337f2ba862e0719b8201cd54aaed967406c748e009191d47efdd", size = 1940029, upload-time = "2026-04-17T09:12:21.5Z" }, - { url = "https://files.pythonhosted.org/packages/b1/44/3d39f782bc82ddd0b2d82bde83b408aa40a332cdf6f3018acb34e3d4dcfc/pydantic_core-2.46.2-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:b737c0b280f41143266445de2689c0e49c79307e51c44ce3a77fef2bedad4994", size = 1987772, upload-time = "2026-04-17T09:10:02.357Z" }, - { url = "https://files.pythonhosted.org/packages/c4/1a/0242e5b7b6cf51dbccc065029f0420107b6bf7e191fcb918f5cb71218acf/pydantic_core-2.46.2-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1b877d597afb82b4898e35354bba55de6f7f048421ae0edadbb9886ec137b532", size = 2138468, upload-time = "2026-04-17T09:11:51.546Z" }, - { url = "https://files.pythonhosted.org/packages/f3/d2/66c146f421178641bda880b0267c0d57dd84f5fec9ecc8e46be17b480742/pydantic_core-2.46.2-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:e9fcabd1857492b5bf16f90258babde50f618f55d046b1309972da2396321ff9", size = 2091621, upload-time = "2026-04-17T09:12:47.501Z" }, - { url = "https://files.pythonhosted.org/packages/ee/b2/c28419aa9fc8055f4ac8e801d1d11c6357351bfa4321ed9bafab3eb98087/pydantic_core-2.46.2-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:fb3ec2c7f54c07b30d89983ce78dc32c37dd06a972448b8716d609493802d628", size = 1937059, upload-time = "2026-04-17T09:10:53.554Z" }, - { url = "https://files.pythonhosted.org/packages/30/ce/cd0824a2db213dc17113291b7a09b9b0ccd9fbf97daa4b81548703341baf/pydantic_core-2.46.2-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:130a6c837d819ef33e8c2bf702ed2c3429237ea69807f1140943d6f4bdaf52fa", size = 1997278, upload-time = "2026-04-17T09:12:23.784Z" }, - { url = "https://files.pythonhosted.org/packages/c9/69/47283fe3c0c967d3e9e9cd6c42b70907610c8a6f8d6e8381f1bb55f8006c/pydantic_core-2.46.2-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:c2e25417cec5cd9bddb151e33cb08c50160f317479ecc02b22a95ec18f8fe004", size = 2147096, upload-time = "2026-04-17T09:12:43.124Z" }, - { url = "https://files.pythonhosted.org/packages/16/d5/dec7c127fa722ff56e1ccf1e960ae1318a9f66742135e97bf9771447216f/pydantic_core-2.46.2-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:c3ad79ed32004d9de91cacd4b5faaff44d56051392fe1d5526feda596f01af25", size = 2107613, upload-time = "2026-04-17T09:10:36.269Z" }, - { url = "https://files.pythonhosted.org/packages/bc/35/975c109b337260a71c93198baf663982b6b39fe3e584e279548a0969e5d4/pydantic_core-2.46.2-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:d157c48d28eebe5d46906de06a6a2f2c9e00b67d3e42de1f1b9c2d42b810f77c", size = 1947099, upload-time = "2026-04-17T09:12:15.304Z" }, - { url = "https://files.pythonhosted.org/packages/4e/11/52a971a0f9218631690274be533f05e5ddde5547f0823bb3e9dfd1be49f6/pydantic_core-2.46.2-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7b42c6471288dedc979ac8400d9c9770f03967dd187db1f8d3405d4d182cc714", size = 2133866, upload-time = "2026-04-17T09:12:27.994Z" }, - { url = "https://files.pythonhosted.org/packages/fe/7a/33d94d0698602b2d1712e78c703a33952eb2ca69e02e8e4b208e7f6602b5/pydantic_core-2.46.2-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4f27bc4801358dc070d6697b41237fce9923d8e69a1ce1e95606ac36c1552dc1", size = 2161721, upload-time = "2026-04-17T09:11:16.111Z" }, - { url = "https://files.pythonhosted.org/packages/b0/cb/0df7ee0a148e9ce0968a80787967ddca9f6b3f8a49152a881b88da262701/pydantic_core-2.46.2-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:e094a8f85db41aa7f6a45c5dac2950afc9862e66832934231962252b5d284eed", size = 2180175, upload-time = "2026-04-17T09:11:41.577Z" }, - { url = "https://files.pythonhosted.org/packages/8e/a8/258a32878140347532be4e44c6f3b1ace3b52b9c9ca7548a65ce18adf4b4/pydantic_core-2.46.2-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:807eeda5551f6884d3b4421578be37be50ddb7a58832348e99617a6714a73748", size = 2319882, upload-time = "2026-04-17T09:10:21.872Z" }, - { url = "https://files.pythonhosted.org/packages/13/b9/5071c298a0f91314a5402b8c56e0efbcebe77085327d0b4df7dc9cb0b674/pydantic_core-2.46.2-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:fcaa1c3c846a7f6686b38fe493d1b2e8007380e293bfef6a9354563c026cbf36", size = 2348065, upload-time = "2026-04-17T09:11:08.263Z" }, - { url = "https://files.pythonhosted.org/packages/75/f3/0a7087e5f861d66ca64ce927230b397cc264c87b712156e6a93b26a459c8/pydantic_core-2.46.2-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:154dbfdfb11b8cbd8ff4d00d0b81e3d19f4cb4bedd5aa9f091060ba071474c6a", size = 2192159, upload-time = "2026-04-17T09:11:20.123Z" }, +sdist = { url = "https://files.pythonhosted.org/packages/9d/56/921726b776ace8d8f5db44c4ef961006580d91dc52b803c489fafd1aa249/pydantic_core-2.46.4.tar.gz", hash = "sha256:62f875393d7f270851f20523dd2e29f082bcc82292d66db2b64ea71f64b6e1c1", size = 471464, upload-time = "2026-05-06T13:37:06.98Z" } +wheels = [ + { url = "https://files.pythonhosted.org/packages/e7/08/f1ba952f1c8ae5581c70fa9c6da89f247b83e3dd8c09c035d5d7931fc23d/pydantic_core-2.46.4-cp310-cp310-macosx_10_12_x86_64.whl", hash = "sha256:a396dcc17e5a0b164dbe026896245a4fa9ff402edca1dff0be3d53a517f74de4", size = 2113146, upload-time = "2026-05-06T13:37:36.537Z" }, + { url = "https://files.pythonhosted.org/packages/56/c6/65f646c7ff09bd257f660434adb45c4dfcbbcebcc030562fecf6f5bf887d/pydantic_core-2.46.4-cp310-cp310-macosx_11_0_arm64.whl", hash = "sha256:da4b951fe36dc7c3a1ccb4e3cd1747c3542b8c9ceede8fc86cae054e764485f5", size = 1949769, upload-time = "2026-05-06T13:37:46.365Z" }, + { url = "https://files.pythonhosted.org/packages/64/ba/bfb1d928fd5b49e1258935ff104ae356e9fd89384a55bf9f847e9193ad40/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:bb63e0198ca18aad131c089b9204c23079c3afa95487e561f4c522d519e55aba", size = 1974958, upload-time = "2026-05-06T13:37:28.611Z" }, + { url = "https://files.pythonhosted.org/packages/4e/74/76223bfb117b64af743c9b6670d1364516f5c0604f96b48f3272f6af6cc6/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:f47286a97f0bc9b8859519809077b91b2cefe4ae47fcbf5e466a009c1c5d742b", size = 2042118, upload-time = "2026-05-06T13:36:55.216Z" }, + { url = "https://files.pythonhosted.org/packages/cb/7b/848732968bc8f48f3187542f08358b9d842db564147b256669426ebb1652/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:905a0ed8ea6f2d61c1738835f99b699348d7857379083e5fc497fa0c967a407c", size = 2222876, upload-time = "2026-05-06T13:38:25.455Z" }, + { url = "https://files.pythonhosted.org/packages/b5/2f/e90b63ee2e14bd8d3db8f705a6d75d64e6ee1b7c2c8833747ce706e1e0ce/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:ea793e075b70290d89d8142074262885d3f7da19634845135751bd6344f73b50", size = 2286703, upload-time = "2026-05-06T13:37:53.304Z" }, + { url = "https://files.pythonhosted.org/packages/ba/1e/acc4d70f88a0a277e4a1fa77ebb985ceabaf900430f875bf9338e11c9420/pydantic_core-2.46.4-cp310-cp310-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:395aebd9183f9d112f569aeb5b2214d1a10a33bec8456447f7fbdfa51d38d4cd", size = 2092042, upload-time = "2026-05-06T13:38:46.981Z" }, + { url = "https://files.pythonhosted.org/packages/a9/da/0a422b57bf8504102bf3c4ccea9c41bab5a5cee6a54650acf8faf67f5a24/pydantic_core-2.46.4-cp310-cp310-manylinux_2_31_riscv64.whl", hash = "sha256:b078afbc25f3a1436c7a1d2cd3e322497ee99615ba97c563566fdf46aff1ee01", size = 2117231, upload-time = "2026-05-06T13:39:23.146Z" }, + { url = "https://files.pythonhosted.org/packages/bd/2a/2ac13c3af305843e23c5078c53d135656b3f05a2fd78cb7bbbb12e97b473/pydantic_core-2.46.4-cp310-cp310-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:f747929cf940cddb5b3668a390056ddd5ba2e5010615ea2dcf4f9c4f3ab8791d", size = 2168388, upload-time = "2026-05-06T13:40:08.06Z" }, + { url = "https://files.pythonhosted.org/packages/72/04/2beacf7e1607e93eefe4aed1b4709f079b905fb77530179d4f7c71745f22/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_aarch64.whl", hash = "sha256:daa27d92c36f24388fe3ad306b174781c747627f134452e4f128ea00ce1fe8c4", size = 2184769, upload-time = "2026-05-06T13:38:13.901Z" }, + { url = "https://files.pythonhosted.org/packages/9e/29/d2b9fd9f539133548eaf622c06a4ce176cb46ac59f32d0359c4abc0de047/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_armv7l.whl", hash = "sha256:19e51f073cd3df251856a8a4189fbdf1de4012c3ebacfb1884f94f1eb406079f", size = 2319312, upload-time = "2026-05-06T13:39:08.24Z" }, + { url = "https://files.pythonhosted.org/packages/7c/af/0f7a5b85fec6075bea96e3ef9187de38fccced0de92c1e7feda8d5cc7bb9/pydantic_core-2.46.4-cp310-cp310-musllinux_1_1_x86_64.whl", hash = "sha256:c1747f85cee84c26985853c6f3d9bd3e75da5212912443fa111c113b9c246f39", size = 2361817, upload-time = "2026-05-06T13:38:43.2Z" }, + { url = "https://files.pythonhosted.org/packages/25/a4/73363fec545fd3ec025490bdda2743c56d0dd5b6266b1a53bbe9e4265375/pydantic_core-2.46.4-cp310-cp310-win32.whl", hash = "sha256:2f84c03c8607173d16b5a854ec68a2f9079ae03237a54fb506d13af47e1d018d", size = 1987085, upload-time = "2026-05-06T13:39:25.497Z" }, + { url = "https://files.pythonhosted.org/packages/01/aa/62f082da2c91fac1c234bc9ee0066257ce83f0604abd72e4c9d5991f2d84/pydantic_core-2.46.4-cp310-cp310-win_amd64.whl", hash = "sha256:8358a950c8909158e3df31538a7e4edc2d7265a7c54b47f0864d9e5bae9dcebf", size = 2074311, upload-time = "2026-05-06T13:39:59.922Z" }, + { url = "https://files.pythonhosted.org/packages/5c/fa/6d7708d2cfc1a832acb6aeb0cd16e801902df8a0f583bb3b4b527fde022e/pydantic_core-2.46.4-cp311-cp311-macosx_10_12_x86_64.whl", hash = "sha256:0e96592440881c74a213e5ad528e2b24d3d4f940de2766bed9010ab1d9e51594", size = 2111872, upload-time = "2026-05-06T13:40:27.596Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6f/aa064a3e74b5745afbdf250594f38e7ead05e2d651bcb35994b9417a0d4d/pydantic_core-2.46.4-cp311-cp311-macosx_11_0_arm64.whl", hash = "sha256:e0d65b8c354be7fb5f720c3caa8bc940bc2d20ce749c8e06135f07f8ed95dd7c", size = 1948255, upload-time = "2026-05-06T13:39:12.574Z" }, + { url = "https://files.pythonhosted.org/packages/43/3a/41114a9f7569b84b4d84e7a018c57c56347dac30c0d4a872946ec4e36c46/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:7bfb192b3f4b9e8a89b6277b6ce787564f62cfd272055f6e685726b111dc7826", size = 1972827, upload-time = "2026-05-06T13:38:19.841Z" }, + { url = "https://files.pythonhosted.org/packages/ef/25/1ab42e8048fe551934d9884e8d64daa7e990ad386f310a15981aeb6a5b08/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:9037063db01f09b09e237c282b6792bd4da634b5402c4e7f0c61effed7701a04", size = 2041051, upload-time = "2026-05-06T13:38:10.447Z" }, + { url = "https://files.pythonhosted.org/packages/94/c2/1a934597ddf08da410385b3b7aae91956a5a76c635effef456074fad7e88/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:fc010ab034c8c7452522748bf937df58020d256ccae0874463d1f4d01758af8e", size = 2221314, upload-time = "2026-05-06T13:40:13.089Z" }, + { url = "https://files.pythonhosted.org/packages/02/6d/9e8ad178c9c4df27ad3c8f25d1fe2a7ab0d2ba0559fad4aee5d3d1f16771/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:8c5dac79fa1614d1e06ca695109c6105923bd9c7d1d6c918d4e637b7e6b32fd3", size = 2285146, upload-time = "2026-05-06T13:38:59.224Z" }, + { url = "https://files.pythonhosted.org/packages/80/50/540cd3aeefc041beb111125c4bff779831a2111fc6b15a9138cda277d32c/pydantic_core-2.46.4-cp311-cp311-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:f9fa868638bf362d3d138ea55829cefb3d5f4b0d7f142234382a15e2485dbec4", size = 2089685, upload-time = "2026-05-06T13:38:17.762Z" }, + { url = "https://files.pythonhosted.org/packages/6b/a4/b440ad35f05f6a38f89fa0f149accb3f0e02be94ca5e15f3c449a61b4bc9/pydantic_core-2.46.4-cp311-cp311-manylinux_2_31_riscv64.whl", hash = "sha256:17299feefe090f2caa5b8e37222bb5f663e4935a8bfa6931d4102e5df1a9f398", size = 2115420, upload-time = "2026-05-06T13:37:58.195Z" }, + { url = "https://files.pythonhosted.org/packages/99/61/de4f55db8dfd57bfdfa9a12ec90fe1b57c4f41062f7ca86f08586b3e0ac0/pydantic_core-2.46.4-cp311-cp311-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:4c63ebc82684aa89d9a3bcbd13d515b3be44250dc68dd3bd81526c1cb31286c3", size = 2165122, upload-time = "2026-05-06T13:37:01.167Z" }, + { url = "https://files.pythonhosted.org/packages/f7/52/7c529d7bdb2d1068bd52f51fe32572c8301f9a4febf1948f10639f1436f5/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_aarch64.whl", hash = "sha256:aaa2a54443eff1950ba5ddc6b6ccda0d9c84a364276a62f969bdf2a390650848", size = 2182573, upload-time = "2026-05-06T13:38:45.04Z" }, + { url = "https://files.pythonhosted.org/packages/37/b3/7c40325848ba78247f2812dcf9c7274e38cd801820ca6dd9fe63bcfb0eb4/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_armv7l.whl", hash = "sha256:18e5ceec2ab67e6d5f1a9085e5a24c9c4e2ac4545730bfe668680bca05e555f3", size = 2317139, upload-time = "2026-05-06T13:37:15.539Z" }, + { url = "https://files.pythonhosted.org/packages/d9/37/f913f81a657c865b75da6c0dbed79876073c2a43b5bd9edbe8da785e4d49/pydantic_core-2.46.4-cp311-cp311-musllinux_1_1_x86_64.whl", hash = "sha256:a0f62d0a58f4e7da165457e995725421e0064f2255d8eccebc49f41bbc23b109", size = 2360433, upload-time = "2026-05-06T13:37:30.099Z" }, + { url = "https://files.pythonhosted.org/packages/c4/67/6acaa1be2567f9256b056d8477158cac7240813956ce86e49deae8e173b4/pydantic_core-2.46.4-cp311-cp311-win32.whl", hash = "sha256:041bde0a48fd37cf71cab1c9d56d3e8625a3793fef1f7dd232b3ff37e978ecda", size = 1985513, upload-time = "2026-05-06T13:38:15.669Z" }, + { url = "https://files.pythonhosted.org/packages/aa/e6/c505f83dfeda9a2e5c995cfd872949e4d05e12f7feb3dca72f633daefa94/pydantic_core-2.46.4-cp311-cp311-win_amd64.whl", hash = "sha256:6f2eeda33a839975441c86a4119e1383c50b47faf0cbb5176985565c6bb02c33", size = 2071114, upload-time = "2026-05-06T13:40:35.416Z" }, + { url = "https://files.pythonhosted.org/packages/0f/da/7a263a96d965d9d0df5e8de8a475f33495451117035b09acb110288c381f/pydantic_core-2.46.4-cp311-cp311-win_arm64.whl", hash = "sha256:14f4c5d6db102bd796a627bbb3a17b4cf4574b9ae861d8b7c9a9661c6dd3362d", size = 2044298, upload-time = "2026-05-06T13:38:29.754Z" }, + { url = "https://files.pythonhosted.org/packages/ce/8c/af022f0af448d7747c5154288d46b5f2bc5f17366eaa0e23e9aa04d59f3b/pydantic_core-2.46.4-cp312-cp312-macosx_10_12_x86_64.whl", hash = "sha256:3245406455a5d98187ec35530fd772b1d799b26667980872c8d4614991e2c4a2", size = 2106158, upload-time = "2026-05-06T13:38:57.215Z" }, + { url = "https://files.pythonhosted.org/packages/19/95/6195171e385007300f0f5574592e467c568becce2d937a0b6804f218bc49/pydantic_core-2.46.4-cp312-cp312-macosx_11_0_arm64.whl", hash = "sha256:962ccbab7b642487b1d8b7df90ef677e03134cf1fd8880bf698649b22a69371f", size = 1951724, upload-time = "2026-05-06T13:37:02.697Z" }, + { url = "https://files.pythonhosted.org/packages/8e/bc/f47d1ff9cbb1620e1b5b697eef06010035735f07820180e74178226b27b3/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:8233f2947cf85404441fd7e0085f53b10c93e0ee78611099b5c7237e36aacbf7", size = 1975742, upload-time = "2026-05-06T13:37:09.448Z" }, + { url = "https://files.pythonhosted.org/packages/5b/11/9b9a5b0306345664a2da6410877af6e8082481b5884b3ddd78d47c6013ce/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:3a233125ac121aa3ffba9a2b59edfc4a985a76092dc8279586ab4b71390875e7", size = 2052418, upload-time = "2026-05-06T13:37:38.234Z" }, + { url = "https://files.pythonhosted.org/packages/f1/b7/a65fec226f5d78fc39f4a13c4cc0c768c22b113438f60c14adc9d2865038/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:5b712b53160b79a5850310b912a5ef8e57e56947c8ad690c227f5c9d7e561712", size = 2232274, upload-time = "2026-05-06T13:38:27.753Z" }, + { url = "https://files.pythonhosted.org/packages/68/f0/92039db98b907ef49269a8271f67db9cb78ae2fc68062ef7e4e77adb5f61/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:9401557acd873c3a7f3eb9383edef8ac4968f9510e340f4808d427e75667e7b4", size = 2309940, upload-time = "2026-05-06T13:38:05.353Z" }, + { url = "https://files.pythonhosted.org/packages/5f/97/2aab507d3d00ca626e8e57c1eac6a79e4e5fbcc63eb99733ff55d1717f65/pydantic_core-2.46.4-cp312-cp312-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:926c9541b14b12b1681dca8a0b75feb510b06c6341b70a8e500c2fdcff837cce", size = 2094516, upload-time = "2026-05-06T13:39:10.577Z" }, + { url = "https://files.pythonhosted.org/packages/22/37/a8aca44d40d737dde2bc05b3c6c07dff0de07ce6f82e9f3167aeaf4d5dea/pydantic_core-2.46.4-cp312-cp312-manylinux_2_31_riscv64.whl", hash = "sha256:56cb4851bcaf3d117eddcef4fe66afd750a50274b0da8e22be256d10e5611987", size = 2136854, upload-time = "2026-05-06T13:40:22.59Z" }, + { url = "https://files.pythonhosted.org/packages/24/99/fcef1b79238c06a8cbec70819ac722ba76e02bc8ada9b0fd66eba40da01b/pydantic_core-2.46.4-cp312-cp312-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:c68fcd102d71ea85c5b2dfac3f4f8476eff42a9e078fd5faefff6d145063536b", size = 2180306, upload-time = "2026-05-06T13:40:10.666Z" }, + { url = "https://files.pythonhosted.org/packages/ae/6c/fc44000918855b42779d007ae63b0532794739027b2f417321cddbc44f6a/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_aarch64.whl", hash = "sha256:b2f69dec1725e79a012d920df1707de5caf7ed5e08f3be4435e25803efc47458", size = 2190044, upload-time = "2026-05-06T13:40:43.231Z" }, + { url = "https://files.pythonhosted.org/packages/6b/65/d9cadc9f1920d7a127ad2edba16c1db7916e59719285cd6c94600b0080ba/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_armv7l.whl", hash = "sha256:8d0820e8192167f80d88d64038e609c31452eeca865b4e1d9950a27a4609b00b", size = 2329133, upload-time = "2026-05-06T13:39:57.365Z" }, + { url = "https://files.pythonhosted.org/packages/d0/cf/c873d91679f3a30bcf5e7ac280ce5573483e72295307685120d0d5ad3416/pydantic_core-2.46.4-cp312-cp312-musllinux_1_1_x86_64.whl", hash = "sha256:fbdb89b3e1c94a30cc5edfce477c6e6a5dc4d8f84665b455c27582f211a1c72c", size = 2374464, upload-time = "2026-05-06T13:38:06.976Z" }, + { url = "https://files.pythonhosted.org/packages/47/bd/6f2fc8188f31bf10590f1e98e7b306336161fac930a8c514cd7bd828c7dc/pydantic_core-2.46.4-cp312-cp312-win32.whl", hash = "sha256:9aa768456404a8bf48a4406685ac2bec8e72b62c69313734fa3b73cf33b3a894", size = 1974823, upload-time = "2026-05-06T13:40:47.985Z" }, + { url = "https://files.pythonhosted.org/packages/40/8c/985c1d41ea1107c2534abd9870e4ed5c8e7669b5c308297835c001e7a1c4/pydantic_core-2.46.4-cp312-cp312-win_amd64.whl", hash = "sha256:e9c26f834c65f5752f3f06cb08cb86a913ceb7274d0db6e267808a708b46bc89", size = 2072919, upload-time = "2026-05-06T13:39:21.153Z" }, + { url = "https://files.pythonhosted.org/packages/c4/ba/f463d006e0c47373ca7ec5e1a261c59dc01ef4d62b2657af925fb0deee3a/pydantic_core-2.46.4-cp312-cp312-win_arm64.whl", hash = "sha256:4fc73cb559bdb54b1134a706a2802a4cddd27a0633f5abb7e53056268751ac6a", size = 2027604, upload-time = "2026-05-06T13:39:03.753Z" }, + { url = "https://files.pythonhosted.org/packages/51/a2/5d30b469c5267a17b39dec53208222f76a8d351dfac4af661888c5aee77d/pydantic_core-2.46.4-cp313-cp313-macosx_10_12_x86_64.whl", hash = "sha256:5d5902252db0d3cedf8d4a1bc68f70eeb430f7e4c7104c8c476753519b423008", size = 2106306, upload-time = "2026-05-06T13:37:48.029Z" }, + { url = "https://files.pythonhosted.org/packages/c1/81/4fa520eaffa8bd7d1525e644cd6d39e7d60b1592bc5b516693c7340b50f1/pydantic_core-2.46.4-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:c94f0688e7b8d0a67abf40e57a7eaaecd17cc9586706a31b76c031f63df052b4", size = 1951906, upload-time = "2026-05-06T13:37:17.012Z" }, + { url = "https://files.pythonhosted.org/packages/03/d5/fd02da45b659668b05923b17ba3a0100a0a3d5541e3bd8fcc4ecb711309e/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:f027324c56cd5406ca49c124b0db10e56c69064fec039acc571c29020cc87c76", size = 1976802, upload-time = "2026-05-06T13:37:35.113Z" }, + { url = "https://files.pythonhosted.org/packages/21/f2/95727e1368be3d3ed485eaab7adbd7dda408f33f7a36e8b48e0144002b91/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:e739fee756ba1010f8bcccb534252e85a35fe45ae92c295a06059ce58b74ccd3", size = 2052446, upload-time = "2026-05-06T13:37:12.313Z" }, + { url = "https://files.pythonhosted.org/packages/9c/86/5d99feea3f77c7234b8718075b23db11532773c1a0dbd9b9490215dc2eeb/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:9d56801be94b86a9da183e5f3766e6310752b99ff647e38b09a9500d88e46e76", size = 2232757, upload-time = "2026-05-06T13:39:01.149Z" }, + { url = "https://files.pythonhosted.org/packages/d2/3a/508ac615935ef7588cf6d9e9b91309fdc2da751af865e02a9098de88258c/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:2412e734dcb48da14d4e4006b82b46b74f2518b8a26ee7e58c6844a6cd6d03c4", size = 2309275, upload-time = "2026-05-06T13:37:41.406Z" }, + { url = "https://files.pythonhosted.org/packages/07/f8/41db9de19d7987d6b04715a02b3b40aea467000275d9d758ffaa31af7d50/pydantic_core-2.46.4-cp313-cp313-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:9551187363ffc0de2a00b2e47c25aeaeb1020b69b668762966df15fc5659dd5a", size = 2094467, upload-time = "2026-05-06T13:39:18.847Z" }, + { url = "https://files.pythonhosted.org/packages/2c/e2/f35033184cb11d0052daf4416e8e10a502ea2ac006fc4f459aee872727d1/pydantic_core-2.46.4-cp313-cp313-manylinux_2_31_riscv64.whl", hash = "sha256:0186750b482eefa11d7f435892b09c5c606193ef3375bcf94aa00ae6bfb66262", size = 2134417, upload-time = "2026-05-06T13:40:17.944Z" }, + { url = "https://files.pythonhosted.org/packages/7e/7b/6ceeb1cc90e193862f444ebe373d8fdf613f0a82572dde03fb10734c6c71/pydantic_core-2.46.4-cp313-cp313-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:5855698a4856556d86e8e6cd8434bc3ac0314ee8e12089ae0e143f64c6256e4e", size = 2179782, upload-time = "2026-05-06T13:40:32.618Z" }, + { url = "https://files.pythonhosted.org/packages/5a/f2/c8d7773ede6af08036423a00ae0ceffce266c3c52a096c435d68c896083f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_aarch64.whl", hash = "sha256:cbaf13819775b7f769bf4a1f066cb6df7a28d4480081a589828ef190226881cd", size = 2188782, upload-time = "2026-05-06T13:36:51.018Z" }, + { url = "https://files.pythonhosted.org/packages/59/31/0c864784e31f09f05cdd87606f08923b9c9e7f6e51dd27f20f62f975ce9f/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_armv7l.whl", hash = "sha256:633147d34cf4550417f12e2b1a0383973bdf5cdfde212cb09e9a581cf10820be", size = 2328334, upload-time = "2026-05-06T13:40:37.764Z" }, + { url = "https://files.pythonhosted.org/packages/c2/eb/4f6c8a41efa30baa755590f4141abf3a8c370fab610915733e74134a7270/pydantic_core-2.46.4-cp313-cp313-musllinux_1_1_x86_64.whl", hash = "sha256:82cf5301172168103724d49a1444d3378cb20cdee30b116a1bd6031236298a5d", size = 2372986, upload-time = "2026-05-06T13:39:34.152Z" }, + { url = "https://files.pythonhosted.org/packages/5b/24/b375a480d53113860c299764bfe9f349a3dc9108b3adc0d7f0d786492ebf/pydantic_core-2.46.4-cp313-cp313-win32.whl", hash = "sha256:9fa8ae11da9e2b3126c6426f147e0fba88d96d65921799bb30c6abd1cb2c97fb", size = 1973693, upload-time = "2026-05-06T13:37:55.072Z" }, + { url = "https://files.pythonhosted.org/packages/7e/e8/cff247591966f2d22ec8c003cd7587e27b7ba7b81ab2fb888e3ab75dc285/pydantic_core-2.46.4-cp313-cp313-win_amd64.whl", hash = "sha256:6b3ace8194b0e5204818c92802dcdca7fc6d88aabbb799d7c795540d9cd6d292", size = 2071819, upload-time = "2026-05-06T13:38:49.139Z" }, + { url = "https://files.pythonhosted.org/packages/c6/1a/f4aee670d5670e9e148e0c82c7db98d780be566c6e6a97ee8035528ca0b3/pydantic_core-2.46.4-cp313-cp313-win_arm64.whl", hash = "sha256:184c081504d17f1c1066e430e117142b2c77d9448a97f7b65c6ac9fd9aee238d", size = 2027411, upload-time = "2026-05-06T13:40:45.796Z" }, + { url = "https://files.pythonhosted.org/packages/8d/74/228a26ddad29c6672b805d9fd78e8d251cd04004fa7eed0e622096cd0250/pydantic_core-2.46.4-cp314-cp314-macosx_10_12_x86_64.whl", hash = "sha256:428e04521a40150c85216fc8b85e8d39fece235a9cf5e383761238c7fa9b96fb", size = 2102079, upload-time = "2026-05-06T13:38:41.019Z" }, + { url = "https://files.pythonhosted.org/packages/ad/1f/8970b150a4b4365623ae00fc88603491f763c627311ae8031e3111356d6e/pydantic_core-2.46.4-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:23ace664830ee0bfe014a0c7bc248b1f7f25ed7ad103852c317624a1083af462", size = 1952179, upload-time = "2026-05-06T13:36:59.812Z" }, + { url = "https://files.pythonhosted.org/packages/95/30/5211a831ae054928054b2f79731661087a2bc5c01e825c672b3a4a8f1b3e/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ce5c1d2a8b27468f433ca974829c44060b8097eedc39933e3c206a90ee49c4a9", size = 1978926, upload-time = "2026-05-06T13:37:39.933Z" }, + { url = "https://files.pythonhosted.org/packages/57/e9/689668733b1eb67adeef047db3c2e8788fcf65a7fd9c9e2b46b7744fe245/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:7283d57845ecf5a163403eb0702dfc220cc4fbdd18919cb5ccea4f95ee1cdab4", size = 2046785, upload-time = "2026-05-06T13:38:01.995Z" }, + { url = "https://files.pythonhosted.org/packages/60/d9/6715260422ff50a2109878fd24d948a6c3446bb2664f34ee78cd972b3acd/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:8daafc69c93ee8a0204506a3b6b30f586ef54028f52aeeeb5c4cfc5184fd5914", size = 2228733, upload-time = "2026-05-06T13:40:50.371Z" }, + { url = "https://files.pythonhosted.org/packages/18/ae/fdb2f64316afca925640f8e70bb1a564b0ec2721c1389e25b8eb4bf9a299/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:cd2213145bcc2ba85884d0ac63d222fece9209678f77b9b4d76f054c561adb28", size = 2307534, upload-time = "2026-05-06T13:37:21.531Z" }, + { url = "https://files.pythonhosted.org/packages/89/1d/8eff589b45bb8190a9d12c49cfad0f176a5cbd1534908a6b5125e2886239/pydantic_core-2.46.4-cp314-cp314-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7a5f930472650a82629163023e630d160863fce524c616f4e5186e5de9d9a49b", size = 2099732, upload-time = "2026-05-06T13:39:31.942Z" }, + { url = "https://files.pythonhosted.org/packages/06/d5/ee5a3366637fee41dee51a1fc91562dcf12ddbc68fda34e6b253da2324bb/pydantic_core-2.46.4-cp314-cp314-manylinux_2_31_riscv64.whl", hash = "sha256:c1b3f518abeca3aa13c712fd202306e145abf59a18b094a6bafb2d2bbf59192c", size = 2129627, upload-time = "2026-05-06T13:37:25.033Z" }, + { url = "https://files.pythonhosted.org/packages/94/33/2414be571d2c6a6c4d08be21f9292b6d3fdb08949a97b6dfe985017821db/pydantic_core-2.46.4-cp314-cp314-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:1a7dd0b3ee80d90150e3495a3a13ac34dbcbfd4f012996a6a1d8900e91b5c0fb", size = 2179141, upload-time = "2026-05-06T13:37:14.046Z" }, + { url = "https://files.pythonhosted.org/packages/7b/79/7daa95be995be0eecc4cf75064cb33f9bbbfe3fe0158caf2f0d4a996a5c7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_aarch64.whl", hash = "sha256:3fb702cd90b0446a3a1c5e470bfa0dd23c0233b676a9099ddcc964fa6ca13898", size = 2184325, upload-time = "2026-05-06T13:36:53.615Z" }, + { url = "https://files.pythonhosted.org/packages/9f/cb/d0a382f5c0de8a222dc61c65348e0ce831b1f68e0a018450d31c2cace3a5/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_armv7l.whl", hash = "sha256:b8458003118a712e66286df6a707db01c52c0f52f7db8e4a38f0da1d3b94fc4e", size = 2323990, upload-time = "2026-05-06T13:40:29.971Z" }, + { url = "https://files.pythonhosted.org/packages/05/db/d9ba624cc4a5aced1598e88c04fdbd8310c8a69b9d38b9a3d39ce3a61ed7/pydantic_core-2.46.4-cp314-cp314-musllinux_1_1_x86_64.whl", hash = "sha256:372429a130e469c9cd698925ce5fc50940b7a1336b0d82038e63d5bbc4edc519", size = 2369978, upload-time = "2026-05-06T13:37:23.027Z" }, + { url = "https://files.pythonhosted.org/packages/f2/20/d15df15ba918c423461905802bfd2981c3af0bfa0e40d05e13edbfa48bc3/pydantic_core-2.46.4-cp314-cp314-win32.whl", hash = "sha256:85bb3611ff1802f3ee7fdd7dbff26b56f343fb432d57a4728fdd49b6ef35e2f4", size = 1966354, upload-time = "2026-05-06T13:38:03.499Z" }, + { url = "https://files.pythonhosted.org/packages/fc/b6/6b8de4c0a7d7ab3004c439c80c5c1e0a3e8d78bbae19379b01960383d9e5/pydantic_core-2.46.4-cp314-cp314-win_amd64.whl", hash = "sha256:811ff8e9c313ab425368bcbb36e5c4ebd7108c2bbf4e4089cfbb0b01eff63fac", size = 2072238, upload-time = "2026-05-06T13:39:40.807Z" }, + { url = "https://files.pythonhosted.org/packages/32/36/51eb763beec1f4cf59b1db243a7dcc39cbb41230f050a09b9d69faaf0a48/pydantic_core-2.46.4-cp314-cp314-win_arm64.whl", hash = "sha256:bfec22eab3c8cc2ceec0248aec886624116dc079afa027ecc8ad4a7e62010f8a", size = 2018251, upload-time = "2026-05-06T13:37:26.72Z" }, + { url = "https://files.pythonhosted.org/packages/e8/91/855af51d625b23aa987116a19e231d2aaef9c4a415273ddc189b79a45fee/pydantic_core-2.46.4-cp314-cp314t-macosx_10_12_x86_64.whl", hash = "sha256:af8244b2bef6aaad6d92cda81372de7f8c8d36c9f0c3ea36e827c60e7d9467a0", size = 2099593, upload-time = "2026-05-06T13:39:47.682Z" }, + { url = "https://files.pythonhosted.org/packages/fb/1b/8784a54c65edb5f49f0a14d6977cf1b209bba85a4c77445b255c2de58ab3/pydantic_core-2.46.4-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:5a4330cdbc57162e4b3aa303f588ba752257694c9c9be3e7ebb11b4aca659b5d", size = 1935226, upload-time = "2026-05-06T13:40:40.428Z" }, + { url = "https://files.pythonhosted.org/packages/e8/e7/1955d28d1afc56dd4b3ad7cc0cf39df1b9852964cf16e5d13912756d6d6b/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:29c61fc04a3d840155ff08e475a04809278972fe6aef51e2720554e96367e34b", size = 1974605, upload-time = "2026-05-06T13:37:32.029Z" }, + { url = "https://files.pythonhosted.org/packages/93/e2/3fedbf0ba7a22850e6e9fd78117f1c0f10f950182344d8a6c535d468fdd8/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:c50f2528cf200c5eed56faf3f4e22fcd5f38c157a8b78576e6ba3168ec35f000", size = 2030777, upload-time = "2026-05-06T13:38:55.239Z" }, + { url = "https://files.pythonhosted.org/packages/f8/61/46be275fcaaba0b4f5b9669dd852267ce1ff616592dccf7a7845588df091/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:0cbe8b01f948de4286c74cdd6c667aceb38f5c1e26f0693b3983d9d74887c65e", size = 2236641, upload-time = "2026-05-06T13:37:08.096Z" }, + { url = "https://files.pythonhosted.org/packages/60/db/12e93e46a8bac9988be3c016860f83293daea8c716c029c9ace279036f2f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:617d7e2ca7dcb8c5cf6bcb8c59b8832c94b36196bbf1cbd1bfb56ed341905edd", size = 2286404, upload-time = "2026-05-06T13:40:20.221Z" }, + { url = "https://files.pythonhosted.org/packages/e2/4a/4d8b19008f38d31c53b8219cfedc2e3d5de5fe99d90076b7e767de29274f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:7027560ee92211647d0d34e3f7cd6f50da56399d26a9c8ad0da286d3869a53f3", size = 2109219, upload-time = "2026-05-06T13:38:12.153Z" }, + { url = "https://files.pythonhosted.org/packages/88/70/3cbc40978fefb7bb09c6708d40d4ad1a5d70fd7213c3d17f971de868ec1f/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_31_riscv64.whl", hash = "sha256:f99626688942fb746e545232e7726926f3be91b5975f8b55327665fafda991c7", size = 2110594, upload-time = "2026-05-06T13:40:02.971Z" }, + { url = "https://files.pythonhosted.org/packages/9d/20/b8d36736216e29491125531685b2f9e61aa5b4b2599893f8268551da3338/pydantic_core-2.46.4-cp314-cp314t-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:fc3e9034a63de20e15e8ade85358bc6efc614008cab72898b4b4952bea0509ff", size = 2159542, upload-time = "2026-05-06T13:39:27.506Z" }, + { url = "https://files.pythonhosted.org/packages/1d/a2/367df868eb584dacf6bf82a389272406d7178e301c4ac82545ab98bc2dd9/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_aarch64.whl", hash = "sha256:97e7cf2be5c77b7d1a9713a05605d49460d02c6078d38d8bef3cbe323c548424", size = 2168146, upload-time = "2026-05-06T13:38:31.93Z" }, + { url = "https://files.pythonhosted.org/packages/c1/b8/4460f77f7e201893f649a29ab355dddd3beee8a97bcb1a320db414f9a06e/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_armv7l.whl", hash = "sha256:3bf92c5d0e00fefaab325a4d27828fe6b6e2a21848686b5b60d2d9eeb09d76c6", size = 2306309, upload-time = "2026-05-06T13:37:44.717Z" }, + { url = "https://files.pythonhosted.org/packages/64/c4/be2639293acd87dc8ddbcec41a73cee9b2ebf996fe6d892a1a74e88ad3f7/pydantic_core-2.46.4-cp314-cp314t-musllinux_1_1_x86_64.whl", hash = "sha256:3ecbc122d18468d06ca279dc26a8c2e2d5acb10943bb35e36ae92096dc3b5565", size = 2369736, upload-time = "2026-05-06T13:37:05.645Z" }, + { url = "https://files.pythonhosted.org/packages/30/a6/9f9f380dbb301f67023bf8f707aaa75daadf84f7152d95c410fd7e81d994/pydantic_core-2.46.4-cp314-cp314t-win32.whl", hash = "sha256:e846ae7835bf0703ae43f534ab79a867146dadd59dc9ca5c8b53d5c8f7c9ef02", size = 1955575, upload-time = "2026-05-06T13:38:51.116Z" }, + { url = "https://files.pythonhosted.org/packages/40/1f/f1eb9eb350e795d1af8586289746f5c5677d16043040d63710e22abc43c9/pydantic_core-2.46.4-cp314-cp314t-win_amd64.whl", hash = "sha256:2108ba5c1c1eca18030634489dc544844144ee36357f2f9f780b93e7ddbb44b5", size = 2051624, upload-time = "2026-05-06T13:38:21.672Z" }, + { url = "https://files.pythonhosted.org/packages/f6/d2/42dd53d0a85c27606f316d3aa5d2869c4e8470a5ed6dec30e4a1abe19192/pydantic_core-2.46.4-cp314-cp314t-win_arm64.whl", hash = "sha256:4fcbe087dbc2068af7eda3aa87634eba216dbda64d1ae73c8684b621d33f6596", size = 2017325, upload-time = "2026-05-06T13:40:52.723Z" }, + { url = "https://files.pythonhosted.org/packages/ee/a4/73995fd4ebbb46ba0ee51e6fa049b8f02c40daebb762208feda8a6b7894d/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_10_12_x86_64.whl", hash = "sha256:14d4edf427bdcf950a8a02d7cb44a08614388dd6e1bdcbf4f67504fa7887da9c", size = 2111589, upload-time = "2026-05-06T13:37:10.817Z" }, + { url = "https://files.pythonhosted.org/packages/fb/7f/f37d3a5e8bfcc2e403f5c57a730f2d815693fb42119e8ea48b3789335af1/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-macosx_11_0_arm64.whl", hash = "sha256:0ce40cd7b21210e99342afafbd4d0f76d784eb5b1d60f3bdc566be4983c6c73b", size = 1944552, upload-time = "2026-05-06T13:36:56.717Z" }, + { url = "https://files.pythonhosted.org/packages/15/3c/d7eb777b3ff43e8433a4efb39a17aa8fd98a4ee8561a24a67ef5db07b2d6/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:90884113d8b48f760e9587002789ddd741e76ab9f89518cd1e43b1f1a52ec44b", size = 1982984, upload-time = "2026-05-06T13:39:06.207Z" }, + { url = "https://files.pythonhosted.org/packages/63/87/70b9f40170a81afd55ca26c9b2acb25c20d64bcfbf888fafecb3ba077d4c/pydantic_core-2.46.4-graalpy311-graalpy242_311_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:66ce7632c22d837c95301830e111ad0128a32b8207533b60896a96c4915192ea", size = 2138417, upload-time = "2026-05-06T13:39:45.476Z" }, + { url = "https://files.pythonhosted.org/packages/9d/1d/8987ad40f65ae1432753072f214fb5c74fe47ffbd0698bb9cbbb585664f8/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_10_12_x86_64.whl", hash = "sha256:1d8ba486450b14f3b1d63bc521d410ec7565e52f887b9fb671791886436a42f7", size = 2095527, upload-time = "2026-05-06T13:39:52.283Z" }, + { url = "https://files.pythonhosted.org/packages/64/d3/84c282a7eee1d3ac4c0377546ef5a1ea436ce26840d9ac3b7ed54a377507/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-macosx_11_0_arm64.whl", hash = "sha256:3009f12e4e90b7f88b4f9adb1b0c4a3d58fe7820f3238c190047209d148026df", size = 1936024, upload-time = "2026-05-06T13:40:15.671Z" }, + { url = "https://files.pythonhosted.org/packages/d7/ca/eac61596cdeb4d7e174d3dc0bd8a6238f14f75f97a24e7b7db4c7e7340a0/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:ad785e92e6dc634c21555edc8bd6b64957ab844541bcb96a1366c202951ae526", size = 1990696, upload-time = "2026-05-06T13:38:34.717Z" }, + { url = "https://files.pythonhosted.org/packages/fa/c3/7c8b240552251faf6b3a957db200fcfbbcec36763c050428b601e0c9b83b/pydantic_core-2.46.4-graalpy312-graalpy250_312_native-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:00c603d540afdd6b80eb39f078f33ebd46211f02f33e34a32d9f053bba711de0", size = 2147590, upload-time = "2026-05-06T13:39:29.883Z" }, + { url = "https://files.pythonhosted.org/packages/11/cb/428de0385b6c8d44b716feba566abfacfbd23ee3c4439faa789a1456242f/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_10_12_x86_64.whl", hash = "sha256:0c563b08bca408dc7f65f700633d8442fffb2421fc47b8101377e9fd65051ff0", size = 2112782, upload-time = "2026-05-06T13:37:04.016Z" }, + { url = "https://files.pythonhosted.org/packages/0b/b5/6a17bdadd0fc1f170adfd05a20d37c832f52b117b4d9131da1f41bb097ce/pydantic_core-2.46.4-pp311-pypy311_pp73-macosx_11_0_arm64.whl", hash = "sha256:db06ffe51636ffe9ca531fe9023dd64bdd794be8754cb5df57c5498ae5b518a7", size = 1952146, upload-time = "2026-05-06T13:39:43.092Z" }, + { url = "https://files.pythonhosted.org/packages/2a/dc/03734d80e362cd43ef65428e9de77c730ce7f2f11c60d2b1e1b39f0fbf99/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:133878133d271ade3d41d1bfb2a45ec38dbdbda40bc065921c6b04e4630127e2", size = 2134492, upload-time = "2026-05-06T13:36:58.124Z" }, + { url = "https://files.pythonhosted.org/packages/de/df/5e5ffc085ed07cc22d298134d3d911c63e91f6a0eb91fe646750a3209910/pydantic_core-2.46.4-pp311-pypy311_pp73-manylinux_2_5_i686.manylinux1_i686.whl", hash = "sha256:9bc519fbf2b7578398853d815009ae5e4d4603d12f4e3f91da8c06852d3da3e9", size = 2156604, upload-time = "2026-05-06T13:37:49.88Z" }, + { url = "https://files.pythonhosted.org/packages/81/44/6e112a4253e56f5705467cbab7ab5e91ee7398ba3d56d358635958893d3e/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_aarch64.whl", hash = "sha256:c7a7bd4e39e8e4c12c39cd480356842b6a8a06e41b23a55a5e3e191718838ddf", size = 2183828, upload-time = "2026-05-06T13:37:43.053Z" }, + { url = "https://files.pythonhosted.org/packages/ac/ad/5565071e937d8e752842ac241463944c9eb14c87e2d269f2658a5bd05e98/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_armv7l.whl", hash = "sha256:d396ec2b979760aaf3218e76c24e65bd0aca24983298653b3a9d7a45f9e47b30", size = 2310000, upload-time = "2026-05-06T13:37:56.694Z" }, + { url = "https://files.pythonhosted.org/packages/4f/c3/66883a5cec183e7fba4d024b4cbbe61851a63750ef606b0afecc46d1f2bf/pydantic_core-2.46.4-pp311-pypy311_pp73-musllinux_1_1_x86_64.whl", hash = "sha256:86e1a4418c6cd97d60c95c71164158eaf7324fae7b0923264016baa993eba6fc", size = 2361286, upload-time = "2026-05-06T13:40:05.667Z" }, + { url = "https://files.pythonhosted.org/packages/4b/2d/69abac8f838090bbecd5df894befb2c2619e7996a98ddb949db9f3b93225/pydantic_core-2.46.4-pp311-pypy311_pp73-win_amd64.whl", hash = "sha256:d51026d73fcfd93610abc7b27789c26b313920fcfb20e27462d74a7f8b06e983", size = 2193071, upload-time = "2026-05-06T13:38:08.682Z" }, ] [[package]] @@ -3555,15 +3520,15 @@ wheels = [ [[package]] name = "python-discovery" -version = "1.2.2" +version = "1.3.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "filelock" }, { name = "platformdirs" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/de/ef/3bae0e537cfe91e8431efcba4434463d2c5a65f5a89edd47c6cf2f03c55f/python_discovery-1.2.2.tar.gz", hash = "sha256:876e9c57139eb757cb5878cbdd9ae5379e5d96266c99ef731119e04fffe533bb", size = 58872, upload-time = "2026-04-07T17:28:49.249Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d8/db/795879cc3ddfe338599bddea6388cc5100b088db0a4caf6e6c1af1c27e04/python_discovery-1.2.2-py3-none-any.whl", hash = "sha256:e1ae95d9af875e78f15e19aed0c6137ab1bb49c200f21f5061786490c9585c7a", size = 31894, upload-time = "2026-04-07T17:28:48.09Z" }, + { 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" }, ] [[package]] @@ -3941,18 +3906,6 @@ wheels = [ { 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.3" -source = { registry = "https://pypi.org/simple" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/52/c9/24906128a455d2de1e08ad05b6de7a0b25002e1cc1db941b7ad4a9314f6e/qir_qis-0.1.3-cp310-abi3-macosx_13_0_arm64.whl", hash = "sha256:e1704efcafea5983d686b8658f4c8dff9110229af6f47bd2d5b5213a7256aeb3", size = 15959593, upload-time = "2026-02-24T22:56:11.581Z" }, - { url = "https://files.pythonhosted.org/packages/65/02/bd01b83fe4a811d1e2e0c20ccd49e92289e561a74480cafdfc7c00ef98f1/qir_qis-0.1.3-cp310-abi3-macosx_13_0_x86_64.whl", hash = "sha256:9a0e488bdd4015330602645aa77002f9f970764ba4ccb7b8548490aa7c3de5ed", size = 17550477, upload-time = "2026-02-24T22:56:14.353Z" }, - { url = "https://files.pythonhosted.org/packages/81/d0/817ee7e71154d79be5e7f0c6fda45f925261b22b0b5abaf3d9932366f1ec/qir_qis-0.1.3-cp310-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:eff1fc1bc282e33707658c65ca483bc9f5558c618f746920004d74dca9ba48c6", size = 17527772, upload-time = "2026-02-24T22:56:16.882Z" }, - { url = "https://files.pythonhosted.org/packages/3f/18/43aaac65f8d6637db0dc18ce6fa7e5458f7924dae9a1b22b9ec84b985bcb/qir_qis-0.1.3-cp310-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:5864b1165a08270a327b80ccb5c02b28544e6e40860a1ca3ec4976b99183f7e8", size = 18805051, upload-time = "2026-02-24T22:56:19.111Z" }, - { url = "https://files.pythonhosted.org/packages/f3/16/25aa2d6ac5dba9d052c4b58049452d0174f4b2a049e6a38e4540c4a72a46/qir_qis-0.1.3-cp310-abi3-win_amd64.whl", hash = "sha256:855bc462e4f31d0dc05cba063f7632610d16a1c66e40ef99ace215355ce76faa", size = 15688465, upload-time = "2026-02-24T22:56:21.464Z" }, -] - [[package]] name = "quantum-pecos" version = "0.8.0.dev8" @@ -4039,7 +3992,7 @@ wheels = [ [[package]] name = "requests" -version = "2.33.1" +version = "2.34.0" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "certifi" }, @@ -4047,9 +4000,9 @@ dependencies = [ { name = "idna" }, { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/5f/a4/98b9c7c6428a668bf7e42ebb7c79d576a1c3c1e3ae2d47e674b468388871/requests-2.33.1.tar.gz", hash = "sha256:18817f8c57c6263968bc123d237e3b8b08ac046f5456bd1e307ee8f4250d3517", size = 134120, upload-time = "2026-03-30T16:09:15.531Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/d7/8e/7540e8a2036f79a125c1d2ebadf69ed7901608859186c856fa0388ef4197/requests-2.33.1-py3-none-any.whl", hash = "sha256:4e6d1ef462f3626a1f0a0a9c42dd93c63bad33f9f1c1937509b8c5c8718ab56a", size = 64947, upload-time = "2026-03-30T16:09:13.83Z" }, + { 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" }, ] [[package]] @@ -4222,27 +4175,27 @@ wheels = [ [[package]] name = "ruff" -version = "0.15.11" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/e4/8d/192f3d7103816158dfd5ea50d098ef2aec19194e6cbccd4b3485bdb2eb2d/ruff-0.15.11.tar.gz", hash = "sha256:f092b21708bf0e7437ce9ada249dfe688ff9a0954fc94abab05dcea7dcd29c33", size = 4637264, upload-time = "2026-04-16T18:46:26.58Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/02/1e/6aca3427f751295ab011828e15e9bf452200ac74484f1db4be0197b8170b/ruff-0.15.11-py3-none-linux_armv6l.whl", hash = "sha256:e927cfff503135c558eb581a0c9792264aae9507904eb27809cdcff2f2c847b7", size = 10607943, upload-time = "2026-04-16T18:46:05.967Z" }, - { url = "https://files.pythonhosted.org/packages/e7/26/1341c262e74f36d4e84f3d6f4df0ac68cd53331a66bfc5080daa17c84c0b/ruff-0.15.11-py3-none-macosx_10_12_x86_64.whl", hash = "sha256:7a1b5b2938d8f890b76084d4fa843604d787a912541eae85fd7e233398bbb73e", size = 10988592, upload-time = "2026-04-16T18:46:00.742Z" }, - { url = "https://files.pythonhosted.org/packages/03/71/850b1d6ffa9564fbb6740429bad53df1094082fe515c8c1e74b6d8d05f18/ruff-0.15.11-py3-none-macosx_11_0_arm64.whl", hash = "sha256:d4176f3d194afbdaee6e41b9ccb1a2c287dba8700047df474abfbe773825d1cb", size = 10338501, upload-time = "2026-04-16T18:46:03.723Z" }, - { url = "https://files.pythonhosted.org/packages/f2/11/cc1284d3e298c45a817a6aadb6c3e1d70b45c9b36d8d9cce3387b495a03a/ruff-0.15.11-py3-none-manylinux_2_17_aarch64.manylinux2014_aarch64.whl", hash = "sha256:3b17c886fb88203ced3afe7f14e8d5ae96e9d2f4ccc0ee66aa19f2c2675a27e4", size = 10670693, upload-time = "2026-04-16T18:46:41.941Z" }, - { url = "https://files.pythonhosted.org/packages/ce/9e/f8288b034ab72b371513c13f9a41d9ba3effac54e24bfb467b007daee2ca/ruff-0.15.11-py3-none-manylinux_2_17_armv7l.manylinux2014_armv7l.whl", hash = "sha256:49fafa220220afe7758a487b048de4c8f9f767f37dfefad46b9dd06759d003eb", size = 10416177, upload-time = "2026-04-16T18:46:21.717Z" }, - { url = "https://files.pythonhosted.org/packages/85/71/504d79abfd3d92532ba6bbe3d1c19fada03e494332a59e37c7c2dabae427/ruff-0.15.11-py3-none-manylinux_2_17_i686.manylinux2014_i686.whl", hash = "sha256:f2ab8427e74a00d93b8bda1307b1e60970d40f304af38bccb218e056c220120d", size = 11221886, upload-time = "2026-04-16T18:46:15.086Z" }, - { url = "https://files.pythonhosted.org/packages/43/5a/947e6ab7a5ad603d65b474be15a4cbc6d29832db5d762cd142e4e3a74164/ruff-0.15.11-py3-none-manylinux_2_17_ppc64le.manylinux2014_ppc64le.whl", hash = "sha256:195072c0c8e1fc8f940652073df082e37a5d9cb43b4ab1e4d0566ab8977a13b7", size = 12075183, upload-time = "2026-04-16T18:46:07.944Z" }, - { url = "https://files.pythonhosted.org/packages/9f/a1/0b7bb6268775fdd3a0818aee8efd8f5b4e231d24dd4d528ced2534023182/ruff-0.15.11-py3-none-manylinux_2_17_s390x.manylinux2014_s390x.whl", hash = "sha256:a3a0996d486af3920dec930a2e7daed4847dfc12649b537a9335585ada163e9e", size = 11516575, upload-time = "2026-04-16T18:46:31.687Z" }, - { url = "https://files.pythonhosted.org/packages/30/c3/bb5168fc4d233cc06e95f482770d0f3c87945a0cd9f614b90ea8dc2f2833/ruff-0.15.11-py3-none-manylinux_2_17_x86_64.manylinux2014_x86_64.whl", hash = "sha256:1bef2cb556d509259f1fe440bb9cd33c756222cf0a7afe90d15edf0866702431", size = 11306537, upload-time = "2026-04-16T18:46:36.988Z" }, - { url = "https://files.pythonhosted.org/packages/e4/92/4cfae6441f3967317946f3b788136eecf093729b94d6561f963ed810c82e/ruff-0.15.11-py3-none-manylinux_2_31_riscv64.whl", hash = "sha256:030d921a836d7d4a12cf6e8d984a88b66094ccb0e0f17ddd55067c331191bf19", size = 11296813, upload-time = "2026-04-16T18:46:24.182Z" }, - { url = "https://files.pythonhosted.org/packages/43/26/972784c5dde8313acde8ac71ba8ac65475b85db4a2352a76c9934361f9bc/ruff-0.15.11-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:0e783b599b4577788dbbb66b9addcef87e9a8832f4ce0c19e34bf55543a2f890", size = 10633136, upload-time = "2026-04-16T18:46:39.802Z" }, - { url = "https://files.pythonhosted.org/packages/5b/53/3985a4f185020c2f367f2e08a103032e12564829742a1b417980ce1514a0/ruff-0.15.11-py3-none-musllinux_1_2_armv7l.whl", hash = "sha256:ae90592246625ba4a34349d68ec28d4400d75182b71baa196ddb9f82db025ef5", size = 10424701, upload-time = "2026-04-16T18:46:10.381Z" }, - { url = "https://files.pythonhosted.org/packages/d3/57/bf0dfb32241b56c83bb663a826133da4bf17f682ba8c096973065f6e6a68/ruff-0.15.11-py3-none-musllinux_1_2_i686.whl", hash = "sha256:1f111d62e3c983ed20e0ca2e800f8d77433a5b1161947df99a5c2a3fb60514f0", size = 10873887, upload-time = "2026-04-16T18:46:29.157Z" }, - { url = "https://files.pythonhosted.org/packages/02/05/e48076b2a57dc33ee8c7a957296f97c744ca891a8ffb4ffb1aaa3b3f517d/ruff-0.15.11-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:06f483d6646f59eaffba9ae30956370d3a886625f511a3108994000480621d1c", size = 11404316, upload-time = "2026-04-16T18:46:19.462Z" }, - { url = "https://files.pythonhosted.org/packages/88/27/0195d15fe7a897cbcba0904792c4b7c9fdd958456c3a17d2ea6093716a9a/ruff-0.15.11-py3-none-win32.whl", hash = "sha256:476a2aa56b7da0b73a3ee80b6b2f0e19cce544245479adde7baa65466664d5f3", size = 10655535, upload-time = "2026-04-16T18:46:12.47Z" }, - { url = "https://files.pythonhosted.org/packages/3a/5e/c927b325bd4c1d3620211a4b96f47864633199feed60fa936025ab27e090/ruff-0.15.11-py3-none-win_amd64.whl", hash = "sha256:8b6756d88d7e234fb0c98c91511aae3cd519d5e3ed271cae31b20f39cb2a12a3", size = 11779692, upload-time = "2026-04-16T18:46:17.268Z" }, - { url = "https://files.pythonhosted.org/packages/63/b6/aeadee5443e49baa2facd51131159fd6301cc4ccfc1541e4df7b021c37dd/ruff-0.15.11-py3-none-win_arm64.whl", hash = "sha256:063fed18cc1bbe0ee7393957284a6fe8b588c6a406a285af3ee3f46da2391ee4", size = 11032614, upload-time = "2026-04-16T18:46:34.487Z" }, +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" }, ] [[package]] @@ -4311,11 +4264,9 @@ version = "1.17.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.12' and python_full_version < '3.14' and platform_machine == 'x86_64' and sys_platform == 'darwin'", - "python_full_version == '3.11.*' 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.12' and python_full_version < '3.14' and platform_machine != 'x86_64') or (python_full_version >= '3.12' and python_full_version < '3.14' and sys_platform != 'darwin')", - "(python_full_version == '3.11.*' and platform_machine != 'x86_64') or (python_full_version == '3.11.*' 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')", ] dependencies = [ { name = "numpy", version = "2.4.4", source = { registry = "https://pypi.org/simple" }, marker = "python_full_version >= '3.11'" }, @@ -4386,7 +4337,7 @@ wheels = [ [[package]] name = "selene-core" -version = "0.2.7" +version = "0.2.9" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "hugr" }, @@ -4395,14 +4346,14 @@ dependencies = [ { name = "llvmlite", version = "0.47.0", source = { registry = "https://pypi.org/simple" }, marker = "platform_machine != 'x86_64' or sys_platform != 'darwin'" }, { 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 = "pydantic" }, { name = "pydot" }, { name = "pyyaml" }, - { name = "qir-qis" }, { name = "typing-extensions" }, { name = "ziglang" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/df/e2/46d0a50c46e15ff604e643951f7b72ca99c3aaad549acbf3908c2ec754bd/selene_core-0.2.7-py3-none-any.whl", hash = "sha256:22f4ca6435eb328079ebfe1c73dc64506665e2d64ee6b44079e4246534ea7aa8", size = 30361, upload-time = "2026-04-10T20:55:12.363Z" }, + { 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" }, ] [[package]] @@ -4419,7 +4370,7 @@ wheels = [ [[package]] name = "selene-sim" -version = "0.2.13" +version = "0.2.15" 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'" }, @@ -4430,11 +4381,11 @@ dependencies = [ { name = "tqdm" }, ] wheels = [ - { url = "https://files.pythonhosted.org/packages/df/15/7f58330010c407dd5ebc57cbe5a2e72e14bbac380a6f68a78303bd3fe039/selene_sim-0.2.13-py3-none-macosx_11_0_arm64.whl", hash = "sha256:731947866ba0578782b4ef8511dc05b5226215d7f36e48699ab1a7ca01317fb4", size = 3675784, upload-time = "2026-04-10T21:33:57.549Z" }, - { url = "https://files.pythonhosted.org/packages/6f/e8/d19055240dac113e02abcd947f3215df5b989f75f75ad66ee722824b6cc8/selene_sim-0.2.13-py3-none-macosx_11_0_x86_64.whl", hash = "sha256:677c634b157d374d188600b221c4ddf20a4a61861ec05c8c31feb06ccc59bf0b", size = 3810091, upload-time = "2026-04-10T21:33:59.246Z" }, - { url = "https://files.pythonhosted.org/packages/ae/c0/0d7fd02e43721185439dc5900885ad82be2e1e9e2b7a3ead0df62856b710/selene_sim-0.2.13-py3-none-manylinux_2_28_aarch64.whl", hash = "sha256:d8b9fe4e9480e48f549b5fdb11521946df28117578d57f404618108352eba6ff", size = 4096184, upload-time = "2026-04-10T21:34:01.333Z" }, - { url = "https://files.pythonhosted.org/packages/1d/fe/60c61a9116f6aa37766518f881c18afb630a61369326c88bf620c193810f/selene_sim-0.2.13-py3-none-manylinux_2_28_x86_64.whl", hash = "sha256:462aab4384e1a9c39b6e6b350c581bc97159cabb42b4107efb3083e2d5ad3b96", size = 4242866, upload-time = "2026-04-10T21:34:02.77Z" }, - { url = "https://files.pythonhosted.org/packages/35/c5/5d326369f9695952f94dbf215f16b0e0164151e3b33062b64c952c0e896a/selene_sim-0.2.13-py3-none-win_amd64.whl", hash = "sha256:cad7fa64a91d2b0a2b90ee87ee4ee5b016cad3a9720755b6386f8ccb91fe954d", size = 8912980, upload-time = "2026-04-10T21:34:04.78Z" }, + { 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" }, ] [[package]] @@ -4704,35 +4655,35 @@ wheels = [ [[package]] name = "traitlets" -version = "5.14.3" +version = "5.15.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/eb/79/72064e6a701c2183016abbbfedaba506d81e30e232a68c9f0d6f6fcd1574/traitlets-5.14.3.tar.gz", hash = "sha256:9ed0579d3502c94b4b3732ac120375cda96f923114522847de4b3bb98b96b6b7", size = 161621, upload-time = "2024-04-19T11:11:49.746Z" } +sdist = { url = "https://files.pythonhosted.org/packages/1b/22/40f55b26baeab80c2d7b3f1db0682f8954e4617fee7d90ce634022ef05c6/traitlets-5.15.0.tar.gz", hash = "sha256:4fead733f81cf1c4c938e06f8ca4633896833c9d89eff878159457f4d4392971", size = 163197, upload-time = "2026-05-06T08:05:58.016Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/00/c0/8f5d070730d7836adc9c9b6408dec68c6ced86b304a9b26a14df072a6e8c/traitlets-5.14.3-py3-none-any.whl", hash = "sha256:b74e89e397b1ed28cc831db7aea759ba6640cb3de13090ca145426688ff1ac4f", size = 85359, upload-time = "2024-04-19T11:11:46.763Z" }, + { url = "https://files.pythonhosted.org/packages/da/98/a9937a969d018a23badfea0b381f66783649d48e0ea6c41923265c3cbeb3/traitlets-5.15.0-py3-none-any.whl", hash = "sha256:fb36a18867a6803deab09f3c5e0fa81bb7b26a5c9e82501c9933f759166eff40", size = 85877, upload-time = "2026-05-06T08:05:55.853Z" }, ] [[package]] name = "types-requests" -version = "2.33.0.20260408" +version = "2.33.0.20260508" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "urllib3" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/69/6a/749dc53a54a3f35842c1f8197b3ca6b54af6d7458a1bfc75f6629b6da666/types_requests-2.33.0.20260408.tar.gz", hash = "sha256:95b9a86376807a216b2fb412b47617b202091c3ea7c078f47cc358d5528ccb7b", size = 23882, upload-time = "2026-04-08T04:34:49.33Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/90/b8/78fd6c037de4788c040fdd323b3369804400351b7827473920f6c1d03c10/types_requests-2.33.0.20260408-py3-none-any.whl", hash = "sha256:81f31d5ea4acb39f03be7bc8bed569ba6d5a9c5d97e89f45ac43d819b68ca50f", size = 20739, upload-time = "2026-04-08T04:34:48.325Z" }, + { 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" }, ] [[package]] name = "types-tqdm" -version = "4.67.3.20260408" +version = "4.67.3.20260508" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "types-requests" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/43/42/2e2968e68a694d3dac3a47aa0df06e46be1a6eef498e5bd15f4c54674eb9/types_tqdm-4.67.3.20260408.tar.gz", hash = "sha256:fd849a79891ae7136ed47541aface15c35bd9a13160fa8a93e42e10f60cf4c8d", size = 18119, upload-time = "2026-04-08T04:36:52.488Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/14/5d/7dedddc32ab7bc2344ece772b5e0f03ec63a1d47ad259696689713c1cf50/types_tqdm-4.67.3.20260408-py3-none-any.whl", hash = "sha256:3b9ed74ebef04df8f53d470ffdc84348e93496d8acafa08bf79fafce0f2f5b5d", size = 24561, upload-time = "2026-04-08T04:36:51.538Z" }, + { 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" }, ] [[package]] @@ -4758,11 +4709,11 @@ wheels = [ [[package]] name = "tzdata" -version = "2026.1" +version = "2026.2" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/19/f5/cd531b2d15a671a40c0f66cf06bc3570a12cd56eef98960068ebbad1bf5a/tzdata-2026.1.tar.gz", hash = "sha256:67658a1903c75917309e753fdc349ac0efd8c27db7a0cb406a25be4840f87f98", size = 197639, upload-time = "2026-04-03T11:25:22.002Z" } +sdist = { url = "https://files.pythonhosted.org/packages/ba/19/1b9b0e29f30c6d35cb345486df41110984ea67ae69dddbc0e8a100999493/tzdata-2026.2.tar.gz", hash = "sha256:9173fde7d80d9018e02a662e168e5a2d04f87c41ea174b139fbef642eda62d10", size = 198254, upload-time = "2026-04-24T15:22:08.651Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/b0/70/d460bd685a170790ec89317e9bd33047988e4bce507b831f5db771e142de/tzdata-2026.1-py2.py3-none-any.whl", hash = "sha256:4b1d2be7ac37ceafd7327b961aa3a54e467efbdb563a23655fbfe0d39cfc42a9", size = 348952, upload-time = "2026-04-03T11:25:20.313Z" }, + { url = "https://files.pythonhosted.org/packages/ce/e4/dccd7f47c4b64213ac01ef921a1337ee6e30e8c6466046018326977efd95/tzdata-2026.2-py2.py3-none-any.whl", hash = "sha256:bbe9af844f658da81a5f95019480da3a89415801f6cc966806612cc7169bffe7", size = 349321, upload-time = "2026-04-24T15:22:05.876Z" }, ] [[package]] @@ -4776,16 +4727,16 @@ 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]] name = "virtualenv" -version = "21.2.4" +version = "21.3.1" source = { registry = "https://pypi.org/simple" } dependencies = [ { name = "distlib" }, @@ -4794,28 +4745,9 @@ dependencies = [ { name = "python-discovery" }, { name = "typing-extensions", marker = "python_full_version < '3.11'" }, ] -sdist = { url = "https://files.pythonhosted.org/packages/0c/98/3a7e644e19cb26133488caff231be390579860bbbb3da35913c49a1d0a46/virtualenv-21.2.4.tar.gz", hash = "sha256:b294ef68192638004d72524ce7ef303e9d0cf5a44c95ce2e54a7500a6381cada", size = 5850742, upload-time = "2026-04-14T22:15:31.438Z" } +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" } wheels = [ - { url = "https://files.pythonhosted.org/packages/27/8d/edd0bd910ff803c308ee9a6b7778621af0d10252219ad9f19ef4d4982a61/virtualenv-21.2.4-py3-none-any.whl", hash = "sha256:29d21e941795206138d0f22f4e45ff7050e5da6c6472299fb7103318763861ac", size = 5831232, upload-time = "2026-04-14T22:15:29.342Z" }, -] - -[[package]] -name = "wasmtime" -version = "43.0.0" -source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/c3/0e/967542865d59d9529bab604b9b88f09a92636e69cc4b1d30c5013e854493/wasmtime-43.0.0.tar.gz", hash = "sha256:eb98b8e2bc35d03dd69c9dd095a388044323622526fc94a9406b8efc48ddc259", size = 117449, upload-time = "2026-03-31T19:26:23.663Z" } -wheels = [ - { url = "https://files.pythonhosted.org/packages/10/a9/5e598c9ae8791375fa47b0dad377e0030dcd6da1be527a639670c5a3f9d6/wasmtime-43.0.0-py3-none-android_26_arm64_v8a.whl", hash = "sha256:c52d7bd47481958494b6ef9f0ed56d01ba6d7088cc9adbc1414be899b75bc04d", size = 6895231, upload-time = "2026-03-31T19:26:01.774Z" }, - { url = "https://files.pythonhosted.org/packages/3b/aa/ce764724dcede88f9010963ca7d70d0a79655174599ea85074cb2c656d59/wasmtime-43.0.0-py3-none-android_26_x86_64.whl", hash = "sha256:f65b287290f06751b2c87da3cdb2381b045ac93bc3ee0e3b805c2a6dc5327bc6", size = 7775074, upload-time = "2026-03-31T19:26:04.741Z" }, - { url = "https://files.pythonhosted.org/packages/2a/ca/67db17c3f098894be798457ce261816fb67c0c1b80c1a53ed1dfa8ed4ff1/wasmtime-43.0.0-py3-none-any.whl", hash = "sha256:9441349d9346230420ed24d357d6f8330fe7251ac5938bb892147728bbe731d7", size = 6472597, upload-time = "2026-03-31T19:26:06.61Z" }, - { url = "https://files.pythonhosted.org/packages/bf/87/b9727ac8ecf02d2bd9af838fe6004c028034ce3f38215a22f8e94705b83d/wasmtime-43.0.0-py3-none-macosx_10_13_x86_64.whl", hash = "sha256:0ff3815f63122d2f59e58c626aad3c4592f1cabc0b6bd7dcc1edc3890eb46783", size = 7564987, upload-time = "2026-03-31T19:26:08.492Z" }, - { url = "https://files.pythonhosted.org/packages/08/42/d9588fa6dad9a609e5acaa72d1d5b346b2913f87c2e95d0c7ddadf5e919b/wasmtime-43.0.0-py3-none-macosx_11_0_arm64.whl", hash = "sha256:5a03c7aa03519df58fed5115ad8093d6deac46386115add715e725448e89ab25", size = 6615055, upload-time = "2026-03-31T19:26:10.506Z" }, - { url = "https://files.pythonhosted.org/packages/48/a9/25b27545ad916a169583dbea41a6a03c58fe04c1d05fa39797dc43bd50b9/wasmtime-43.0.0-py3-none-manylinux1_x86_64.whl", hash = "sha256:341542e87caf1f2ef7ff648a78827fcef5751e3e9be2ee07a1fcf3a04413c213", size = 7819110, upload-time = "2026-03-31T19:26:12.335Z" }, - { url = "https://files.pythonhosted.org/packages/d8/9a/4d8760f827931b5b265b83e52316d40b8e0eb999bb8e2d457c2ae172d5cc/wasmtime-43.0.0-py3-none-manylinux2014_aarch64.whl", hash = "sha256:30b042fd4a05d0f8a320baed53fcb971aff8a3789ed6967f4521f87931ace717", size = 6910375, upload-time = "2026-03-31T19:26:14.207Z" }, - { url = "https://files.pythonhosted.org/packages/ce/19/81c748c089a693b102f9a6239f2558a0ffd55fc721fcdd139361aaede1a1/wasmtime-43.0.0-py3-none-musllinux_1_2_aarch64.whl", hash = "sha256:34ff18384ad62625cb1438fd0266f6c74b4a72ddcb8ba30c60a66be3632db44b", size = 6938286, upload-time = "2026-03-31T19:26:15.898Z" }, - { url = "https://files.pythonhosted.org/packages/0f/fa/c37e77c907567a8802696f9ab839b719ea811cf3d59ffc815cc95d894339/wasmtime-43.0.0-py3-none-musllinux_1_2_x86_64.whl", hash = "sha256:c7025d477d807df30dad07c9318ea747c6cfc99764c7cb2a8e44e75b8c43e3be", size = 7852033, upload-time = "2026-03-31T19:26:17.915Z" }, - { url = "https://files.pythonhosted.org/packages/69/67/57c7e361049554cdedd9253e732a6eace5c643488a0e3886ac3f471a4be7/wasmtime-43.0.0-py3-none-win_amd64.whl", hash = "sha256:7e6b0d0641d78012bdf7d3622ca4bc969462dcf1d0a6c147dc5d7aae2f5093a9", size = 6472603, upload-time = "2026-03-31T19:26:19.724Z" }, - { url = "https://files.pythonhosted.org/packages/ec/27/8ecf7dbbb16dc3ab32fcb205f4d798e77cab264118bc1ac52145a76e38fb/wasmtime-43.0.0-py3-none-win_arm64.whl", hash = "sha256:5ddb2ba4b354fc4f055c8ce9285e7bc4cb259c339e5834bb4d0739d644042b8e", size = 5455362, upload-time = "2026-03-31T19:26:21.746Z" }, + { 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" }, ] [[package]] @@ -4852,11 +4784,11 @@ wheels = [ [[package]] name = "wcwidth" -version = "0.6.0" +version = "0.7.0" source = { registry = "https://pypi.org/simple" } -sdist = { url = "https://files.pythonhosted.org/packages/35/a2/8e3becb46433538a38726c948d3399905a4c7cabd0df578ede5dc51f0ec2/wcwidth-0.6.0.tar.gz", hash = "sha256:cdc4e4262d6ef9a1a57e018384cbeb1208d8abbc64176027e2c2455c81313159", size = 159684, upload-time = "2026-02-06T19:19:40.919Z" } +sdist = { url = "https://files.pythonhosted.org/packages/2c/ee/afaf0f85a9a18fe47a67f1e4422ed6cf1fe642f0ae0a2f81166231303c52/wcwidth-0.7.0.tar.gz", hash = "sha256:90e3a7ea092341c44b99562e75d09e4d5160fe7a3974c6fb842a101a95e7eed0", size = 182132, upload-time = "2026-05-02T16:04:12.653Z" } wheels = [ - { url = "https://files.pythonhosted.org/packages/68/5a/199c59e0a824a3db2b89c5d2dade7ab5f9624dbf6448dc291b46d5ec94d3/wcwidth-0.6.0-py3-none-any.whl", hash = "sha256:1a3a1e510b553315f8e146c54764f4fb6264ffad731b3d78088cdb1478ffbdad", size = 94189, upload-time = "2026-02-06T19:19:39.646Z" }, + { url = "https://files.pythonhosted.org/packages/41/52/e465037f5375f43533d1a80b6923955201596a99142ed524d77b571a1418/wcwidth-0.7.0-py3-none-any.whl", hash = "sha256:5d69154c429a82910e241c738cd0e2976fac8a2dd47a1a805f4afed1c0f136f2", size = 110825, upload-time = "2026-05-02T16:04:11.033Z" }, ] [[package]]